From e57fe8c0316a1ec4dcc68a6acc032c05fe25d565 Mon Sep 17 00:00:00 2001 From: sirmacik <127441966+sirmacik@users.noreply.github.com> Date: Sat, 13 Jun 2026 20:38:10 +0000 Subject: [PATCH 1/3] feat: add Vibe Code (vibe) as a supported tool target Add first-class support for Vibe Code so teams can generate and import Vibe's native config from the unified .rulesync source of truth. Native implementations across six features: - rules -> AGENTS.md (project) / ~/.vibe/AGENTS.md (global) - ignore -> .vibeignore - mcp -> .vibe/config.toml ([[mcp_servers]], TOML) - permissions -> .vibe/config.toml (tools / enabled_tools / disabled_tools) - subagents -> .vibe/agents/*.toml (TOML frontmatter) - skills -> .vibe/skills/ (+ .agents/skills/ alt root, project only) Wire vibe into all six feature processors, the gitignore registry, README, and docs/reference/supported-tools.md. MCP and permissions share .vibe/config.toml via read-merge so neither clobbers the other. Canonical edit/write permission categories both map to write_file and merge through a shared tools accumulator. Co-Authored-By: Claude Opus 4.8 --- README.md | 1 + docs/reference/supported-tools.md | 1 + skills/rulesync/supported-tools.md | 1 + src/cli/commands/gitignore-entries.ts | 9 +- src/e2e/e2e-ignore.spec.ts | 3 + src/e2e/e2e-mcp.spec.ts | 93 ++++++ src/e2e/e2e-permissions.spec.ts | 111 +++++++ src/e2e/e2e-rules.spec.ts | 3 + src/e2e/e2e-skills.spec.ts | 31 ++ src/e2e/e2e-subagents.spec.ts | 26 ++ src/features/ignore/ignore-processor.test.ts | 1 + src/features/ignore/ignore-processor.ts | 3 + src/features/ignore/vibe-ignore.test.ts | 48 +++ src/features/ignore/vibe-ignore.ts | 72 +++++ src/features/mcp/mcp-processor.test.ts | 3 + src/features/mcp/mcp-processor.ts | 14 + src/features/mcp/vibe-mcp.test.ts | 144 +++++++++ src/features/mcp/vibe-mcp.ts | 259 +++++++++++++++ .../permissions/permissions-processor.test.ts | 3 + .../permissions/permissions-processor.ts | 13 + .../permissions/vibe-permissions.test.ts | 141 ++++++++ src/features/permissions/vibe-permissions.ts | 303 ++++++++++++++++++ src/features/rules/rules-processor.test.ts | 4 +- src/features/rules/rules-processor.ts | 16 + src/features/rules/vibe-rule.test.ts | 90 ++++++ src/features/rules/vibe-rule.ts | 136 ++++++++ src/features/skills/rulesync-skill.ts | 16 + src/features/skills/skills-processor.test.ts | 5 + src/features/skills/skills-processor.ts | 12 + src/features/skills/vibe-skill.test.ts | 139 ++++++++ src/features/skills/vibe-skill.ts | 245 ++++++++++++++ src/features/subagents/rulesync-subagent.ts | 16 + .../subagents/subagents-processor.test.ts | 2 + src/features/subagents/subagents-processor.ts | 9 + src/features/subagents/vibe-subagent.test.ts | 142 ++++++++ src/features/subagents/vibe-subagent.ts | 207 ++++++++++++ src/types/tool-targets.test.ts | 1 + src/types/tool-targets.ts | 1 + 38 files changed, 2322 insertions(+), 2 deletions(-) create mode 100644 src/features/ignore/vibe-ignore.test.ts create mode 100644 src/features/ignore/vibe-ignore.ts create mode 100644 src/features/mcp/vibe-mcp.test.ts create mode 100644 src/features/mcp/vibe-mcp.ts create mode 100644 src/features/permissions/vibe-permissions.test.ts create mode 100644 src/features/permissions/vibe-permissions.ts create mode 100644 src/features/rules/vibe-rule.test.ts create mode 100644 src/features/rules/vibe-rule.ts create mode 100644 src/features/skills/vibe-skill.test.ts create mode 100644 src/features/skills/vibe-skill.ts create mode 100644 src/features/subagents/vibe-subagent.test.ts create mode 100644 src/features/subagents/vibe-subagent.ts diff --git a/README.md b/README.md index 125e010cb..f1aed64e1 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,7 @@ The tables below show whether each tool supports a given feature (✅ = supporte | Roo Code | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | | | Rovodev (Atlassian) | ✅ | | ✅ | | ✅ | ✅ | | | | Takt | ✅ | | | ✅ | ✅ | ✅ | | | +| Vibe Code | ✅ | ✅ | ✅ | | ✅ | ✅ | | ✅ | | Qwen Code | ✅ | ✅ | | | | | | ✅ | | Kiro | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Google Antigravity IDE | ✅ | | ✅ | ✅ | | ✅ | ✅ | ✅ | diff --git a/docs/reference/supported-tools.md b/docs/reference/supported-tools.md index adb9e1bcf..3398d10e7 100644 --- a/docs/reference/supported-tools.md +++ b/docs/reference/supported-tools.md @@ -22,6 +22,7 @@ Rulesync supports both **generation** and **import** for All of the major AI cod | Roo Code | roo | ✅ | ✅ | ✅ | ✅ | 🎮 | ✅ 🌏 | | | | Rovodev (Atlassian) | rovodev | ✅ 🌏 | | 🌏 | | ✅ 🌏 | ✅ 🌏 | | | | Takt | takt | ✅ 🌏 | | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | | +| Vibe Code | vibe | ✅ 🌏 | ✅ | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | | ✅ 🌏 | | Qwen Code | qwencode | ✅ | ✅ | | | | | | ✅ 🌏 | | Kiro | kiro | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Google Antigravity IDE | antigravity-ide | ✅ 🌏 | | ✅ 🌏 🔧 | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ | diff --git a/skills/rulesync/supported-tools.md b/skills/rulesync/supported-tools.md index adb9e1bcf..3398d10e7 100644 --- a/skills/rulesync/supported-tools.md +++ b/skills/rulesync/supported-tools.md @@ -22,6 +22,7 @@ Rulesync supports both **generation** and **import** for All of the major AI cod | Roo Code | roo | ✅ | ✅ | ✅ | ✅ | 🎮 | ✅ 🌏 | | | | Rovodev (Atlassian) | rovodev | ✅ 🌏 | | 🌏 | | ✅ 🌏 | ✅ 🌏 | | | | Takt | takt | ✅ 🌏 | | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | | +| Vibe Code | vibe | ✅ 🌏 | ✅ | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | | ✅ 🌏 | | Qwen Code | qwencode | ✅ | ✅ | | | | | | ✅ 🌏 | | Kiro | kiro | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Google Antigravity IDE | antigravity-ide | ✅ 🌏 | | ✅ 🌏 🔧 | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ | diff --git a/src/cli/commands/gitignore-entries.ts b/src/cli/commands/gitignore-entries.ts index ade11461c..e23c56c11 100644 --- a/src/cli/commands/gitignore-entries.ts +++ b/src/cli/commands/gitignore-entries.ts @@ -39,7 +39,7 @@ export const GITIGNORE_ENTRY_REGISTRY: ReadonlyArray = [ // AGENTS.md { - target: ["agentsmd", "amp", "antigravity-ide", "pi", "warp"], + target: ["agentsmd", "amp", "antigravity-ide", "pi", "vibe", "warp"], feature: "rules", entry: "**/AGENTS.md", }, @@ -335,6 +335,13 @@ export const GITIGNORE_ENTRY_REGISTRY: ReadonlyArray = [ { target: "devin", feature: "skills", entry: "**/.devin/skills/" }, { target: "devin", feature: "skills", entry: "**/.codeium/windsurf/skills/" }, + // Vibe + { target: "vibe", feature: "ignore", entry: "**/.vibeignore" }, + { target: "vibe", feature: "skills", entry: "**/.vibe/skills/" }, + { target: "vibe", feature: "subagents", entry: "**/.vibe/agents/" }, + { target: "vibe", feature: "mcp", entry: "**/.vibe/config.toml" }, + { target: "vibe", feature: "permissions", entry: "**/.vibe/config.toml" }, + // Warp // `/init` now writes `AGENTS.md` (handled by the shared AGENTS.md entry above). { target: "warp", feature: "rules", entry: "**/.warp/" }, diff --git a/src/e2e/e2e-ignore.spec.ts b/src/e2e/e2e-ignore.spec.ts index 8f3113244..3319bd5d8 100644 --- a/src/e2e/e2e-ignore.spec.ts +++ b/src/e2e/e2e-ignore.spec.ts @@ -32,6 +32,7 @@ describe("E2E: ignore", () => { outputPath: join(".zed", "settings.json"), format: "json" as const, }, + { target: "vibe", outputPath: ".vibeignore", format: "plaintext" as const }, ])("should generate $target ignore", async ({ target, outputPath, format }) => { const testDir = getTestDir(); @@ -81,6 +82,7 @@ credentials/ { target: "junie", orphanPath: ".aiignore" }, { target: "augmentcode", orphanPath: ".augmentignore" }, { target: "devin", orphanPath: ".devinignore" }, + { target: "vibe", orphanPath: ".vibeignore" }, // zed ignore uses .zed/settings.json which is not deletable by rulesync ])( "should fail in check mode when delete would remove an orphan $target ignore file", @@ -146,6 +148,7 @@ describe("E2E: ignore (import)", () => { { target: "junie", sourcePath: ".aiignore" }, { target: "augmentcode", sourcePath: ".augmentignore" }, { target: "devin", sourcePath: ".devinignore" }, + { target: "vibe", sourcePath: ".vibeignore" }, ])("should import $target ignore", async ({ target, sourcePath }) => { const testDir = getTestDir(); diff --git a/src/e2e/e2e-mcp.spec.ts b/src/e2e/e2e-mcp.spec.ts index b8a26d7b8..794c996a9 100644 --- a/src/e2e/e2e-mcp.spec.ts +++ b/src/e2e/e2e-mcp.spec.ts @@ -2,6 +2,7 @@ import { spawn } from "node:child_process"; import { join } from "node:path"; import { setTimeout } from "node:timers/promises"; +import * as smolToml from "smol-toml"; import { describe, expect, it } from "vitest"; import { RULESYNC_MCP_RELATIVE_FILE_PATH } from "../constants/rulesync-paths.js"; @@ -38,6 +39,7 @@ describe("E2E: mcp", () => { { target: "warp", outputPath: join(".warp", ".mcp.json") }, { target: "zed", outputPath: join(".zed", "settings.json") }, { target: "devin", outputPath: join(".windsurf", "mcp_config.json") }, + { target: "vibe", outputPath: join(".vibe", "config.toml") }, ])("should generate $target mcp", async ({ target, outputPath }) => { const testDir = getTestDir(); @@ -141,6 +143,11 @@ describe("E2E: mcp", () => { outputPath: "kilo.jsonc", content: JSON.stringify({ theme: "dark", mcp: {} }, null, 2), }, + { + target: "vibe", + outputPath: join(".vibe", "config.toml"), + content: 'theme = "dark"\n', + }, ])( "should succeed in check mode when a $target mcp file is non-deletable", async ({ target, outputPath, content }) => { @@ -197,6 +204,54 @@ describe("E2E: mcp", () => { // Verify that there were no actual errors (warnings are acceptable) expect(hasError, `MCP daemon produced errors: ${stderrOutput}`).toBe(false); }); + + it("should generate Vibe MCP and permissions into shared config.toml", async () => { + const testDir = getTestDir(); + + await writeFileContent( + join(testDir, RULESYNC_MCP_RELATIVE_FILE_PATH), + JSON.stringify( + { + mcpServers: { + "test-server": { + type: "stdio", + command: "echo", + args: ["hello"], + }, + }, + }, + null, + 2, + ), + ); + await writeFileContent( + join(testDir, ".rulesync", "permissions.json"), + JSON.stringify( + { + permission: { + bash: { "*": "ask", "git *": "allow" }, + edit: { "*": "deny" }, + }, + }, + null, + 2, + ), + ); + + await runGenerate({ target: "vibe", features: "mcp,permissions" }); + + const parsed = toTable( + smolToml.parse(await readFileContent(join(testDir, ".vibe", "config.toml"))), + ); + const tools = toTable(parsed.tools); + const bash = toTable(tools.bash); + expect(toTableArray(parsed.mcp_servers)).toMatchObject([ + { name: "test-server", command: "echo" }, + ]); + expect(bash.permission).toBe("ask"); + expect(bash.allow).toEqual(["git *"]); + expect(parsed.disabled_tools).toContain("write_file"); + }); }); describe("E2E: mcp (import)", () => { @@ -259,6 +314,29 @@ describe("E2E: mcp (import)", () => { expect(importedContent).toContain("test-server"); }); + it("should import vibe mcp from config.toml", async () => { + const testDir = getTestDir(); + + await writeFileContent( + join(testDir, ".vibe", "config.toml"), + [ + 'theme = "dark"', + "", + "[[mcp_servers]]", + 'name = "test-server"', + 'transport = "stdio"', + 'command = "echo"', + 'args = ["hello"]', + ].join("\n"), + ); + + await runImport({ target: "vibe", features: "mcp" }); + + const importedContent = await readFileContent(join(testDir, RULESYNC_MCP_RELATIVE_FILE_PATH)); + expect(importedContent).toContain("test-server"); + expect(importedContent).toContain("hello"); + }); + // Zed stores MCP servers under `context_servers` (not `mcpServers`) inside a // shared settings.json, so it needs a bespoke source rather than the generic // `mcpServers`-seeded import case above. @@ -320,6 +398,7 @@ describe("E2E: mcp (global mode)", () => { target: "devin", outputPath: join(".codeium", "windsurf", "mcp_config.json"), }, + { target: "vibe", outputPath: join(".vibe", "config.toml") }, ])("should generate $target mcp in home directory", async ({ target, outputPath }) => { const projectDir = getProjectDir(); const homeDir = getHomeDir(); @@ -481,3 +560,17 @@ describe("E2E: mcp (global mode)", () => { expect(generatedContent).not.toContain("non-root-server"); }); }); + +function toTable(value: unknown): Record { + if (value && typeof value === "object" && !Array.isArray(value)) { + return { ...value }; + } + return {}; +} + +function toTableArray(value: unknown): Record[] { + if (!Array.isArray(value)) { + return []; + } + return value.map(toTable); +} diff --git a/src/e2e/e2e-permissions.spec.ts b/src/e2e/e2e-permissions.spec.ts index 9201c1fbe..d2cd7ea71 100644 --- a/src/e2e/e2e-permissions.spec.ts +++ b/src/e2e/e2e-permissions.spec.ts @@ -429,6 +429,46 @@ describe("E2E: permissions", () => { expect(content.permissions.deny).toContain("Read(.env)"); }); + it("should generate vibe permissions into .vibe/config.toml and preserve MCP config", async () => { + const testDir = getTestDir(); + + await writeFileContent( + join(testDir, ".vibe", "config.toml"), + ["[[mcp_servers]]", 'name = "existing"', 'transport = "stdio"', 'command = "node"'].join( + "\n", + ), + ); + await writeFileContent( + join(testDir, RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH), + JSON.stringify( + { + permission: { + bash: { "*": "ask", "git *": "allow", "rm *": "deny" }, + read: { "*": "allow" }, + edit: { "*": "deny" }, + }, + }, + null, + 2, + ), + ); + + await runGenerate({ target: "vibe", features: "permissions" }); + + const parsed = toTable( + smolToml.parse(await readFileContent(join(testDir, ".vibe", "config.toml"))), + ); + const tools = toTable(parsed.tools); + const bash = toTable(tools.bash); + const readFile = toTable(tools.read_file); + expect(toTableArray(parsed.mcp_servers)).toMatchObject([{ name: "existing", command: "node" }]); + expect(bash.permission).toBe("ask"); + expect(bash.allow).toEqual(["git *"]); + expect(bash.deny).toEqual(["rm *"]); + expect(readFile.permission).toBe("always"); + expect(parsed.disabled_tools).toContain("write_file"); + }); + it("should remove denied Kiro web tools from existing allowedTools", async () => { const testDir = getTestDir(); @@ -788,6 +828,34 @@ enabled = true expect(content.permission.read[".env"]).toBe("deny"); expect(content.permission.webfetch["*"]).toBe("allow"); }); + + it("should import vibe permissions into .rulesync/permissions.json", async () => { + const testDir = getTestDir(); + + await writeFileContent( + join(testDir, ".vibe", "config.toml"), + [ + 'enabled_tools = ["read_file"]', + 'disabled_tools = ["write_file"]', + "", + "[tools.bash]", + 'permission = "ask"', + 'allow = ["git *"]', + 'deny = ["rm *"]', + ].join("\n"), + ); + + await runImport({ target: "vibe", features: "permissions" }); + + const content = JSON.parse( + await readFileContent(join(testDir, RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH)), + ); + expect(content.permission.read["*"]).toBe("allow"); + expect(content.permission.edit["*"]).toBe("deny"); + expect(content.permission.bash["*"]).toBe("ask"); + expect(content.permission.bash["git *"]).toBe("allow"); + expect(content.permission.bash["rm *"]).toBe("deny"); + }); }); describe("E2E: permissions (global mode)", () => { @@ -1247,6 +1315,42 @@ describe("E2E: permissions (global mode)", () => { // Unrelated user settings preserved by the non-destructive merge. expect(generated["amp.mcpServers"].my_server.command).toBe("x"); }); + + it("should generate vibe permissions in home directory with --global", async () => { + const projectDir = getProjectDir(); + const homeDir = getHomeDir(); + + await writeFileContent( + join(projectDir, RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH), + JSON.stringify( + { + root: true, + permission: { + bash: { "*": "ask", "git status": "allow" }, + edit: { "*": "deny" }, + }, + }, + null, + 2, + ), + ); + + await runGenerate({ + target: "vibe", + features: "permissions", + global: true, + env: { HOME_DIR: homeDir }, + }); + + const parsed = toTable( + smolToml.parse(await readFileContent(join(homeDir, ".vibe", "config.toml"))), + ); + const tools = toTable(parsed.tools); + const bash = toTable(tools.bash); + expect(bash.permission).toBe("ask"); + expect(bash.allow).toEqual(["git status"]); + expect(parsed.disabled_tools).toContain("write_file"); + }); }); type AugmentEntry = { @@ -1289,3 +1393,10 @@ function toTable(value: unknown): Record { } return {}; } + +function toTableArray(value: unknown): Record[] { + if (!Array.isArray(value)) { + return []; + } + return value.map(toTable); +} diff --git a/src/e2e/e2e-rules.spec.ts b/src/e2e/e2e-rules.spec.ts index 07fe24995..f12da4808 100644 --- a/src/e2e/e2e-rules.spec.ts +++ b/src/e2e/e2e-rules.spec.ts @@ -42,6 +42,7 @@ describe("E2E: rules", () => { { target: "replit", outputPath: "replit.md" }, { target: "pi", outputPath: "AGENTS.md" }, { target: "zed", outputPath: ".rules" }, + { target: "vibe", outputPath: "AGENTS.md" }, ])("should generate $target rules", async ({ target, outputPath }) => { const testDir = getTestDir(); @@ -256,6 +257,7 @@ describe("E2E: rules (import)", () => { { target: "warp", sourcePath: "AGENTS.md", importedFileName: "overview.md" }, { target: "replit", sourcePath: "replit.md", importedFileName: "overview.md" }, { target: "pi", sourcePath: "AGENTS.md", importedFileName: "overview.md" }, + { target: "vibe", sourcePath: "AGENTS.md", importedFileName: "overview.md" }, { target: "cline", sourcePath: join(".clinerules", "overview.md"), @@ -336,6 +338,7 @@ describe("E2E: rules (global mode)", () => { { target: "takt", outputPath: join(".takt", "facets", "policies", "overview.md") }, { target: "pi", outputPath: join(".pi", "agent", "AGENTS.md") }, { target: "zed", outputPath: join(".config", "zed", "AGENTS.md") }, + { target: "vibe", outputPath: join(".vibe", "AGENTS.md") }, { target: "augmentcode", outputPath: join(".augment", "rules", "overview.md") }, { target: "devin", diff --git a/src/e2e/e2e-skills.spec.ts b/src/e2e/e2e-skills.spec.ts index 9fe44c2b8..b70edd060 100644 --- a/src/e2e/e2e-skills.spec.ts +++ b/src/e2e/e2e-skills.spec.ts @@ -115,6 +115,10 @@ describe("E2E: skills", () => { target: "factorydroid", outputPath: join(".factory", "skills", "test-skill", "SKILL.md"), }, + { + target: "vibe", + outputPath: join(".vibe", "skills", "test-skill", "SKILL.md"), + }, ])("should generate $target skills", async ({ target, outputPath }) => { const testDir = getTestDir(); @@ -194,6 +198,7 @@ This is the test skill body content. { target: "pi", orphanPath: join(".pi", "skills", "orphan-skill", "SKILL.md") }, { target: "zed", orphanPath: join(".agents", "skills", "orphan-skill", "SKILL.md") }, { target: "factorydroid", orphanPath: join(".factory", "skills", "orphan-skill", "SKILL.md") }, + { target: "vibe", orphanPath: join(".vibe", "skills", "orphan-skill", "SKILL.md") }, ])( "should fail in check mode when delete would remove an orphan $target skill file", async ({ target, orphanPath }) => { @@ -248,6 +253,7 @@ describe("E2E: skills (import)", () => { { target: "pi", sourcePath: join(".pi", "skills", "test-skill", "SKILL.md") }, { target: "zed", sourcePath: join(".agents", "skills", "test-skill", "SKILL.md") }, { target: "factorydroid", sourcePath: join(".factory", "skills", "test-skill", "SKILL.md") }, + { target: "vibe", sourcePath: join(".vibe", "skills", "test-skill", "SKILL.md") }, ])("should import $target skills", async ({ target, sourcePath }) => { const testDir = getTestDir(); @@ -265,6 +271,27 @@ This is the test skill body content.`; ); expect(importedContent).toContain("test skill body content"); }); + + it("should import vibe skills from the .agents/skills fallback root", async () => { + const testDir = getTestDir(); + + const skillContent = `--- +name: fallback-skill +description: "A fallback Vibe skill" +--- +This is the fallback skill body content.`; + await writeFileContent( + join(testDir, ".agents", "skills", "fallback-skill", "SKILL.md"), + skillContent, + ); + + await runImport({ target: "vibe", features: "skills" }); + + const importedContent = await readFileContent( + join(testDir, RULESYNC_SKILLS_RELATIVE_DIR_PATH, "fallback-skill", "SKILL.md"), + ); + expect(importedContent).toContain("fallback skill body content"); + }); }); describe("E2E: skills (global mode)", () => { @@ -367,6 +394,10 @@ describe("E2E: skills (global mode)", () => { target: "factorydroid", outputPath: join(".factory", "skills", "test-skill", "SKILL.md"), }, + { + target: "vibe", + outputPath: join(".vibe", "skills", "test-skill", "SKILL.md"), + }, ])("should generate $target skills in home directory", async ({ target, outputPath }) => { const projectDir = getProjectDir(); const homeDir = getHomeDir(); diff --git a/src/e2e/e2e-subagents.spec.ts b/src/e2e/e2e-subagents.spec.ts index 06d13b1fb..8f6eaa501 100644 --- a/src/e2e/e2e-subagents.spec.ts +++ b/src/e2e/e2e-subagents.spec.ts @@ -67,6 +67,10 @@ describe("E2E: subagents", () => { target: "cline", outputPath: join(".cline", "agents", "planner.yaml"), }, + { + target: "vibe", + outputPath: join(".vibe", "agents", "planner.toml"), + }, ])("should generate $target subagents", async ({ target, outputPath }) => { const testDir = getTestDir(); @@ -217,6 +221,7 @@ You are a subagent-only helper. { target: "junie", orphanPath: join(".junie", "agents", "orphan.md") }, { target: "factorydroid", orphanPath: join(".factory", "droids", "orphan.md") }, { target: "cline", orphanPath: join(".cline", "agents", "orphan.yaml") }, + { target: "vibe", orphanPath: join(".vibe", "agents", "orphan.toml") }, ])( "should fail in check mode when delete would remove an orphan $target subagent file", async ({ target, orphanPath }) => { @@ -296,6 +301,26 @@ Break down tasks into steps. ); expect(importedContent).toContain("planner"); }); + + it("should import vibe subagents from TOML", async () => { + const testDir = getTestDir(); + + const subagentContent = [ + 'agent_type = "agent"', + 'display_name = "Planner"', + 'description = "Plans implementation tasks"', + 'system_prompt = "Break down tasks into steps."', + ].join("\n"); + await writeFileContent(join(testDir, ".vibe", "agents", "planner.toml"), subagentContent); + + await runImport({ target: "vibe", features: "subagents" }); + + const importedContent = await readFileContent( + join(testDir, RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, "planner.md"), + ); + expect(importedContent).toContain("Planner"); + expect(importedContent).toContain("Break down tasks into steps."); + }); }); describe("E2E: subagents (global mode)", () => { @@ -318,6 +343,7 @@ describe("E2E: subagents (global mode)", () => { target: "deepagents", outputPath: join(".deepagents", "deepagents", "agents", "planner", "AGENTS.md"), }, + { target: "vibe", outputPath: join(".vibe", "agents", "planner.toml") }, ])("should generate $target subagents in home directory", async ({ target, outputPath }) => { const projectDir = getProjectDir(); const homeDir = getHomeDir(); diff --git a/src/features/ignore/ignore-processor.test.ts b/src/features/ignore/ignore-processor.test.ts index d2eb081af..e1d6cd97b 100644 --- a/src/features/ignore/ignore-processor.test.ts +++ b/src/features/ignore/ignore-processor.test.ts @@ -442,6 +442,7 @@ describe("IgnoreProcessor", () => { "qwencode", "roo", "devin", + "vibe", "zed", ]; diff --git a/src/features/ignore/ignore-processor.ts b/src/features/ignore/ignore-processor.ts index d72230d26..26864bf89 100644 --- a/src/features/ignore/ignore-processor.ts +++ b/src/features/ignore/ignore-processor.ts @@ -30,6 +30,7 @@ import { ToolIgnoreSettablePaths, ToolIgnoreSettablePathsParams, } from "./tool-ignore.js"; +import { VibeIgnore } from "./vibe-ignore.js"; import { ZedIgnore } from "./zed-ignore.js"; const ignoreProcessorToolTargets: ToolTarget[] = [ @@ -47,6 +48,7 @@ const ignoreProcessorToolTargets: ToolTarget[] = [ "qwencode", "roo", "devin", + "vibe", "zed", ]; @@ -80,6 +82,7 @@ const toolIgnoreFactories = new Map { + let testDir: string; + let cleanup: () => Promise; + + beforeEach(async () => { + ({ testDir, cleanup } = await setupTestDirectory()); + vi.spyOn(process, "cwd").mockReturnValue(testDir); + }); + + afterEach(async () => { + await cleanup(); + vi.restoreAllMocks(); + }); + + it("should map rulesync ignore content to .vibeignore", () => { + const rulesyncIgnore = new RulesyncIgnore({ + outputRoot: testDir, + relativeDirPath: ".rulesync", + relativeFilePath: ".aiignore", + fileContent: "node_modules\n.env\n", + }); + + const vibeIgnore = VibeIgnore.fromRulesyncIgnore({ outputRoot: testDir, rulesyncIgnore }); + + expect(vibeIgnore.getRelativeDirPath()).toBe("."); + expect(vibeIgnore.getRelativeFilePath()).toBe(".vibeignore"); + expect(vibeIgnore.getFileContent()).toBe("node_modules\n.env\n"); + }); + + it("should import .vibeignore as rulesync ignore", async () => { + await writeFileContent(join(testDir, ".vibeignore"), "dist\n.cache\n"); + + const vibeIgnore = await VibeIgnore.fromFile({ outputRoot: testDir }); + const rulesyncIgnore = vibeIgnore.toRulesyncIgnore(); + + expect(vibeIgnore.getPatterns()).toEqual(["dist", ".cache"]); + expect(rulesyncIgnore.getFileContent()).toBe("dist\n.cache\n"); + }); +}); diff --git a/src/features/ignore/vibe-ignore.ts b/src/features/ignore/vibe-ignore.ts new file mode 100644 index 000000000..9d3e5c612 --- /dev/null +++ b/src/features/ignore/vibe-ignore.ts @@ -0,0 +1,72 @@ +import { join } from "node:path"; + +import { readFileContent } from "../../utils/file.js"; +import { RulesyncIgnore } from "./rulesync-ignore.js"; +import { + ToolIgnore, + ToolIgnoreForDeletionParams, + ToolIgnoreFromFileParams, + ToolIgnoreFromRulesyncIgnoreParams, + ToolIgnoreParams, + ToolIgnoreSettablePaths, +} from "./tool-ignore.js"; + +export type VibeIgnoreParams = ToolIgnoreParams; + +export class VibeIgnore extends ToolIgnore { + static getSettablePaths(): ToolIgnoreSettablePaths { + return { + relativeDirPath: ".", + relativeFilePath: ".vibeignore", + }; + } + + toRulesyncIgnore(): RulesyncIgnore { + return this.toRulesyncIgnoreDefault(); + } + + static fromRulesyncIgnore({ + outputRoot = process.cwd(), + rulesyncIgnore, + }: ToolIgnoreFromRulesyncIgnoreParams): VibeIgnore { + const paths = this.getSettablePaths(); + return new VibeIgnore({ + outputRoot, + relativeDirPath: paths.relativeDirPath, + relativeFilePath: paths.relativeFilePath, + fileContent: rulesyncIgnore.getFileContent(), + }); + } + + static async fromFile({ + outputRoot = process.cwd(), + validate = true, + }: ToolIgnoreFromFileParams): Promise { + const paths = this.getSettablePaths(); + const fileContent = await readFileContent( + join(outputRoot, paths.relativeDirPath, paths.relativeFilePath), + ); + + return new VibeIgnore({ + outputRoot, + relativeDirPath: paths.relativeDirPath, + relativeFilePath: paths.relativeFilePath, + fileContent, + validate, + }); + } + + static forDeletion({ + outputRoot = process.cwd(), + relativeDirPath, + relativeFilePath, + }: ToolIgnoreForDeletionParams): VibeIgnore { + return new VibeIgnore({ + outputRoot, + relativeDirPath, + relativeFilePath, + fileContent: "", + validate: false, + }); + } +} diff --git a/src/features/mcp/mcp-processor.test.ts b/src/features/mcp/mcp-processor.test.ts index 0bee2a965..cdac4f469 100644 --- a/src/features/mcp/mcp-processor.test.ts +++ b/src/features/mcp/mcp-processor.test.ts @@ -1110,6 +1110,7 @@ describe("McpProcessor", () => { expect(targets).toContain("roo"); expect(targets).toContain("codexcli"); // codexcli supports both project and global expect(targets).toContain("kilo"); // kilo supports both project and global + expect(targets).toContain("vibe"); }); it("should include kilo in global tool targets", () => { @@ -1120,6 +1121,7 @@ describe("McpProcessor", () => { expect(globalTargets).toContain("kilo"); expect(globalTargets).toContain("opencode"); // sanity: parity with opencode expect(globalTargets).toContain("cline"); // cline MCP is global-only + expect(globalTargets).toContain("vibe"); }); }); @@ -1133,6 +1135,7 @@ describe("McpProcessor", () => { expect(() => McpProcessorToolTargetSchema.parse("cline")).not.toThrow(); expect(() => McpProcessorToolTargetSchema.parse("codexcli")).not.toThrow(); expect(() => McpProcessorToolTargetSchema.parse("roo")).not.toThrow(); + expect(() => McpProcessorToolTargetSchema.parse("vibe")).not.toThrow(); }); it("should reject invalid tool targets", () => { diff --git a/src/features/mcp/mcp-processor.ts b/src/features/mcp/mcp-processor.ts index 488394a3f..a864721e8 100644 --- a/src/features/mcp/mcp-processor.ts +++ b/src/features/mcp/mcp-processor.ts @@ -36,6 +36,7 @@ import { ToolMcpFromRulesyncMcpParams, ToolMcpSettablePaths, } from "./tool-mcp.js"; +import { VibeMcp } from "./vibe-mcp.js"; import { WarpMcp } from "./warp-mcp.js"; import { ZedMcp } from "./zed-mcp.js"; @@ -65,6 +66,7 @@ const mcpProcessorToolTargetTuple = [ "opencode", "roo", "rovodev", + "vibe", "warp", "devin", "zed", @@ -374,6 +376,18 @@ const toolMcpFactories = new Map([ }, }, ], + [ + "vibe", + { + class: VibeMcp, + meta: { + supportsProject: true, + supportsGlobal: true, + supportsEnabledTools: false, + supportsDisabledTools: false, + }, + }, + ], [ "warp", { diff --git a/src/features/mcp/vibe-mcp.test.ts b/src/features/mcp/vibe-mcp.test.ts new file mode 100644 index 000000000..c2fafd9d5 --- /dev/null +++ b/src/features/mcp/vibe-mcp.test.ts @@ -0,0 +1,144 @@ +import { join } from "node:path"; + +import * as smolToml from "smol-toml"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { setupTestDirectory } from "../../test-utils/test-directories.js"; +import { ensureDir, writeFileContent } from "../../utils/file.js"; +import { RulesyncMcp } from "./rulesync-mcp.js"; +import { VibeMcp } from "./vibe-mcp.js"; + +describe("VibeMcp", () => { + let testDir: string; + let cleanup: () => Promise; + + beforeEach(async () => { + ({ testDir, cleanup } = await setupTestDirectory()); + vi.spyOn(process, "cwd").mockReturnValue(testDir); + }); + + afterEach(async () => { + await cleanup(); + vi.restoreAllMocks(); + }); + + it("should export rulesync MCP servers as Vibe mcp_servers and preserve config keys", async () => { + await ensureDir(join(testDir, ".vibe")); + await writeFileContent( + join(testDir, ".vibe", "config.toml"), + [ + 'active_model = "devstral"', + 'enabled_tools = ["read_file"]', + "", + "[tools.bash]", + 'permission = "ask"', + ].join("\n"), + ); + + const rulesyncMcp = new RulesyncMcp({ + outputRoot: testDir, + relativeDirPath: ".rulesync", + relativeFilePath: "mcp.json", + fileContent: JSON.stringify({ + mcpServers: { + local: { + type: "stdio", + command: "node", + args: ["server.js"], + env: { NODE_ENV: "test" }, + startup_timeout_sec: 5, + }, + remote: { + type: "http", + url: "https://example.com/mcp", + headers: { Authorization: "Bearer token" }, + api_key_env: "MCP_TOKEN", + api_key_header: "Authorization", + api_key_format: "Bearer {token}", + }, + }, + }), + }); + + const vibeMcp = await VibeMcp.fromRulesyncMcp({ outputRoot: testDir, rulesyncMcp }); + const parsed = smolToml.parse(vibeMcp.getFileContent()) as any; + + expect(parsed.active_model).toBe("devstral"); + expect(parsed.enabled_tools).toEqual(["read_file"]); + expect(parsed.tools.bash.permission).toBe("ask"); + expect(parsed.mcp_servers).toMatchObject([ + { + name: "local", + transport: "stdio", + command: "node", + args: ["server.js"], + env: { NODE_ENV: "test" }, + startup_timeout_sec: 5, + }, + { + name: "remote", + transport: "http", + url: "https://example.com/mcp", + headers: { Authorization: "Bearer token" }, + api_key_env: "MCP_TOKEN", + api_key_header: "Authorization", + api_key_format: "Bearer {token}", + }, + ]); + }); + + it("should import Vibe mcp_servers into rulesync mcpServers", () => { + const fileContent = [ + "[[mcp_servers]]", + 'name = "fetch"', + 'transport = "http"', + 'url = "https://example.com/mcp"', + 'headers = { Authorization = "Bearer token" }', + "tool_timeout_sec = 30", + "", + "[[mcp_servers]]", + 'name = "local"', + 'transport = "stdio"', + 'command = "node"', + 'args = ["server.js"]', + 'env = { NODE_ENV = "test" }', + ].join("\n"); + + const vibeMcp = new VibeMcp({ + outputRoot: testDir, + relativeDirPath: ".vibe", + relativeFilePath: "config.toml", + fileContent, + }); + + const rulesyncMcp = vibeMcp.toRulesyncMcp(); + const parsed = JSON.parse(rulesyncMcp.getFileContent()); + + expect(parsed.mcpServers).toMatchObject({ + fetch: { + type: "http", + transport: "http", + url: "https://example.com/mcp", + headers: { Authorization: "Bearer token" }, + tool_timeout_sec: 30, + }, + local: { + type: "stdio", + transport: "stdio", + command: "node", + args: ["server.js"], + env: { NODE_ENV: "test" }, + }, + }); + }); + + it("should not be deletable because config.toml is shared", () => { + const vibeMcp = VibeMcp.forDeletion({ + outputRoot: testDir, + relativeDirPath: ".vibe", + relativeFilePath: "config.toml", + }); + + expect(vibeMcp.isDeletable()).toBe(false); + }); +}); diff --git a/src/features/mcp/vibe-mcp.ts b/src/features/mcp/vibe-mcp.ts new file mode 100644 index 000000000..ecdcee997 --- /dev/null +++ b/src/features/mcp/vibe-mcp.ts @@ -0,0 +1,259 @@ +import { join } from "node:path"; + +import * as smolToml from "smol-toml"; + +import { ValidationResult } from "../../types/ai-file.js"; +import type { McpServer, McpServers } from "../../types/mcp.js"; +import { formatError } from "../../utils/error.js"; +import { readFileContentOrNull } from "../../utils/file.js"; +import { RulesyncMcp } from "./rulesync-mcp.js"; +import { + ToolMcp, + ToolMcpForDeletionParams, + ToolMcpFromFileParams, + ToolMcpFromRulesyncMcpParams, + ToolMcpParams, + ToolMcpSettablePaths, +} from "./tool-mcp.js"; + +type VibeConfig = Record & { + mcp_servers?: VibeMcpServer[]; +}; + +type VibeMcpServer = Record & { + name: string; + transport?: string; +}; + +const VIBE_MCP_SERVER_FIELDS = [ + "transport", + "url", + "headers", + "api_key_env", + "api_key_header", + "api_key_format", + "command", + "args", + "env", + "startup_timeout_sec", + "tool_timeout_sec", +] as const; + +export class VibeMcp extends ToolMcp { + private readonly toml: VibeConfig; + + constructor(params: ToolMcpParams) { + super(params); + this.toml = parseVibeConfig(this.fileContent); + } + + getToml(): VibeConfig { + return this.toml; + } + + override isDeletable(): boolean { + return false; + } + + static getSettablePaths(_options: { global?: boolean } = {}): ToolMcpSettablePaths { + return { + relativeDirPath: ".vibe", + relativeFilePath: "config.toml", + }; + } + + static async fromFile({ + outputRoot = process.cwd(), + validate = true, + global = false, + }: ToolMcpFromFileParams): Promise { + const paths = this.getSettablePaths({ global }); + const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath); + const fileContent = (await readFileContentOrNull(filePath)) ?? smolToml.stringify({}); + const config = parseVibeConfig(fileContent); + config.mcp_servers = normalizeMcpServersArray(config.mcp_servers); + + return new VibeMcp({ + outputRoot, + relativeDirPath: paths.relativeDirPath, + relativeFilePath: paths.relativeFilePath, + fileContent: smolToml.stringify(config), + validate, + global, + }); + } + + static async fromRulesyncMcp({ + outputRoot = process.cwd(), + rulesyncMcp, + validate = true, + global = false, + }: ToolMcpFromRulesyncMcpParams): Promise { + const paths = this.getSettablePaths({ global }); + const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath); + const existingContent = (await readFileContentOrNull(filePath)) ?? smolToml.stringify({}); + const config = parseVibeConfig(existingContent); + + config.mcp_servers = Object.entries(rulesyncMcp.getMcpServers()).map(([name, server]) => + rulesyncMcpServerToVibe(name, server), + ); + + return new VibeMcp({ + outputRoot, + relativeDirPath: paths.relativeDirPath, + relativeFilePath: paths.relativeFilePath, + fileContent: smolToml.stringify(config), + validate, + global, + }); + } + + toRulesyncMcp(): RulesyncMcp { + const mcpServers: McpServers = Object.fromEntries( + normalizeMcpServersArray(this.toml.mcp_servers).map((server) => [ + server.name, + vibeMcpServerToRulesync(server), + ]), + ); + + return this.toRulesyncMcpDefault({ + fileContent: JSON.stringify({ mcpServers }, null, 2), + }); + } + + validate(): ValidationResult { + try { + parseVibeConfig(this.fileContent); + return { success: true, error: null }; + } catch (error) { + return { + success: false, + error: new Error(`Failed to parse Vibe config TOML: ${formatError(error)}`), + }; + } + } + + static forDeletion({ + outputRoot = process.cwd(), + relativeDirPath, + relativeFilePath, + global = false, + }: ToolMcpForDeletionParams): VibeMcp { + return new VibeMcp({ + outputRoot, + relativeDirPath, + relativeFilePath, + fileContent: smolToml.stringify({}), + validate: false, + global, + }); + } +} + +function parseVibeConfig(fileContent: string): VibeConfig { + const parsed = smolToml.parse(fileContent || smolToml.stringify({})); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return {}; + } + return { ...(parsed as Record) }; +} + +function normalizeMcpServersArray(value: unknown): VibeMcpServer[] { + if (!Array.isArray(value)) { + return []; + } + return value + .filter((entry): entry is Record => { + return entry !== null && typeof entry === "object" && !Array.isArray(entry); + }) + .filter((entry): entry is VibeMcpServer => typeof entry.name === "string"); +} + +function rulesyncMcpServerToVibe(name: string, server: McpServer): VibeMcpServer { + const serverRecord = server as Record; + const transport = resolveVibeTransport(server); + const vibeServer: VibeMcpServer = { + name, + ...(transport !== undefined && { transport }), + }; + + if (server.command !== undefined) { + if (Array.isArray(server.command)) { + const [command, ...commandArgs] = server.command; + if (command !== undefined) { + vibeServer.command = command; + } + const args = [...commandArgs, ...(server.args ?? [])]; + if (args.length > 0) { + vibeServer.args = args; + } + } else { + vibeServer.command = server.command; + if (server.args !== undefined) { + vibeServer.args = server.args; + } + } + } + + for (const field of VIBE_MCP_SERVER_FIELDS) { + if (field === "transport" || field === "command" || field === "args") { + continue; + } + if (serverRecord[field] !== undefined) { + vibeServer[field] = serverRecord[field]; + } + } + if (vibeServer.url === undefined && server.httpUrl !== undefined) { + vibeServer.url = server.httpUrl; + } + + return vibeServer; +} + +function vibeMcpServerToRulesync(server: VibeMcpServer): McpServer { + const result: Record = {}; + const transport = typeof server.transport === "string" ? server.transport : undefined; + if (transport !== undefined) { + result.transport = transport; + result.type = transport === "streamable-http" ? "streamable-http" : transport; + } + + for (const field of VIBE_MCP_SERVER_FIELDS) { + if (field === "transport") { + continue; + } + if (server[field] !== undefined) { + result[field] = server[field]; + } + } + + return result as McpServer; +} + +function resolveVibeTransport(server: McpServer): string | undefined { + if (server.transport) { + if (server.transport === "sse") { + return "http"; + } + if (server.transport === "local") { + return "stdio"; + } + return server.transport; + } + if (server.type) { + if (server.type === "sse") { + return "http"; + } + if (server.type === "local") { + return "stdio"; + } + return server.type; + } + if (server.command) { + return "stdio"; + } + if (server.url || server.httpUrl) { + return "http"; + } + return undefined; +} diff --git a/src/features/permissions/permissions-processor.test.ts b/src/features/permissions/permissions-processor.test.ts index 7c0627363..0723110df 100644 --- a/src/features/permissions/permissions-processor.test.ts +++ b/src/features/permissions/permissions-processor.test.ts @@ -92,6 +92,7 @@ describe("PermissionsProcessor", () => { "kiro", "opencode", "qwencode", + "vibe", "zed", ]); }); @@ -110,6 +111,7 @@ describe("PermissionsProcessor", () => { "kilo", "opencode", "qwencode", + "vibe", "warp", "zed", ]); @@ -131,6 +133,7 @@ describe("PermissionsProcessor", () => { "kiro", "opencode", "qwencode", + "vibe", "zed", ]); }); diff --git a/src/features/permissions/permissions-processor.ts b/src/features/permissions/permissions-processor.ts index af466ea58..a02694ca8 100644 --- a/src/features/permissions/permissions-processor.ts +++ b/src/features/permissions/permissions-processor.ts @@ -29,6 +29,7 @@ import type { ToolPermissionsSettablePaths, } from "./tool-permissions.js"; import { ToolPermissions } from "./tool-permissions.js"; +import { VibePermissions } from "./vibe-permissions.js"; import { WarpPermissions } from "./warp-permissions.js"; import { ZedPermissions } from "./zed-permissions.js"; @@ -47,6 +48,7 @@ const permissionsProcessorToolTargetTuple = [ "kiro", "opencode", "qwencode", + "vibe", "warp", "zed", ] as const; @@ -240,6 +242,17 @@ const toolPermissionsFactories = new Map { + let testDir: string; + let cleanup: () => Promise; + + beforeEach(async () => { + ({ testDir, cleanup } = await setupTestDirectory()); + vi.spyOn(process, "cwd").mockReturnValue(testDir); + }); + + afterEach(async () => { + await cleanup(); + vi.restoreAllMocks(); + }); + + it("should export rulesync permissions to Vibe tools and preserve MCP config", async () => { + await ensureDir(join(testDir, ".vibe")); + await writeFileContent( + join(testDir, ".vibe", "config.toml"), + [ + "[[mcp_servers]]", + 'name = "fetch"', + 'transport = "http"', + 'url = "https://example.com/mcp"', + ].join("\n"), + ); + + const rulesyncPermissions = new RulesyncPermissions({ + outputRoot: testDir, + relativeDirPath: ".rulesync", + relativeFilePath: "permissions.json", + fileContent: JSON.stringify({ + permission: { + bash: { + "*": "ask", + "git status": "allow", + "rm -rf *": "deny", + "npm *": "ask", + }, + read: { "*": "allow" }, + edit: { "*": "deny" }, + }, + }), + }); + const logger = createMockLogger(); + + const vibePermissions = await VibePermissions.fromRulesyncPermissions({ + outputRoot: testDir, + rulesyncPermissions, + logger, + }); + const parsed = smolToml.parse(vibePermissions.getFileContent()) as any; + + expect(parsed.mcp_servers).toMatchObject([ + { name: "fetch", transport: "http", url: "https://example.com/mcp" }, + ]); + expect(parsed.tools.bash.permission).toBe("ask"); + expect(parsed.tools.bash.allow).toEqual(["git status"]); + expect(parsed.tools.bash.deny).toEqual(["rm -rf *"]); + expect(parsed.tools.read_file.permission).toBe("always"); + expect(parsed.disabled_tools).toEqual(["write_file"]); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('pattern-level "ask" rules')); + }); + + it("should import Vibe tool filters and per-tool permissions", () => { + const fileContent = [ + 'enabled_tools = ["read_file"]', + 'disabled_tools = ["write_file"]', + "", + "[tools.bash]", + 'permission = "ask"', + 'allow = ["git status"]', + 'deny = ["rm -rf *"]', + ].join("\n"); + + const vibePermissions = new VibePermissions({ + outputRoot: testDir, + relativeDirPath: ".vibe", + relativeFilePath: "config.toml", + fileContent, + }); + + const parsed = JSON.parse(vibePermissions.toRulesyncPermissions().getFileContent()); + + expect(parsed.permission).toEqual({ + read: { "*": "allow" }, + edit: { "*": "deny" }, + bash: { + "*": "ask", + "git status": "allow", + "rm -rf *": "deny", + }, + }); + }); + + it("should not be deletable because config.toml is shared", () => { + const vibePermissions = VibePermissions.forDeletion({ + outputRoot: testDir, + relativeDirPath: ".vibe", + relativeFilePath: "config.toml", + }); + + expect(vibePermissions.isDeletable()).toBe(false); + }); + + it("should merge edit and write categories to same write_file tool", async () => { + const rulesyncPermissions = new RulesyncPermissions({ + outputRoot: testDir, + relativeDirPath: ".rulesync", + relativeFilePath: "permissions.json", + fileContent: JSON.stringify({ + permission: { + edit: { + "*.md": "allow", + }, + write: { + "*.txt": "allow", + }, + }, + }), + }); + + const vibePermissions = await VibePermissions.fromRulesyncPermissions({ + outputRoot: testDir, + rulesyncPermissions, + }); + const parsed = smolToml.parse(vibePermissions.getFileContent()) as any; + + expect(parsed.tools.write_file.allow).toEqual(["*.md", "*.txt"]); + }); +}); diff --git a/src/features/permissions/vibe-permissions.ts b/src/features/permissions/vibe-permissions.ts new file mode 100644 index 000000000..9e0c58450 --- /dev/null +++ b/src/features/permissions/vibe-permissions.ts @@ -0,0 +1,303 @@ +import { join } from "node:path"; + +import * as smolToml from "smol-toml"; + +import type { AiFileParams, ValidationResult } from "../../types/ai-file.js"; +import type { PermissionAction, PermissionsConfig } from "../../types/permissions.js"; +import { formatError } from "../../utils/error.js"; +import { readFileContentOrNull } from "../../utils/file.js"; +import { RulesyncPermissions } from "./rulesync-permissions.js"; +import { + ToolPermissions, + type ToolPermissionsForDeletionParams, + type ToolPermissionsFromFileParams, + type ToolPermissionsFromRulesyncPermissionsParams, + type ToolPermissionsSettablePaths, +} from "./tool-permissions.js"; + +type VibeToolConfig = Record & { + permission?: string; + allow?: string[]; + deny?: string[]; + allowlist?: string[]; + denylist?: string[]; +}; + +type VibeConfig = Record & { + enabled_tools?: string[]; + disabled_tools?: string[]; + tools?: Record; +}; + +const CANONICAL_TO_VIBE_TOOL_NAMES: Record = { + bash: "bash", + read: "read_file", + edit: "write_file", + write: "write_file", + webfetch: "fetch", + websearch: "search_web", +}; + +const VIBE_TO_CANONICAL_TOOL_NAMES: Record = { + bash: "bash", + read_file: "read", + write_file: "edit", + fetch: "webfetch", + search_web: "websearch", +}; + +export class VibePermissions extends ToolPermissions { + private readonly toml: VibeConfig; + + constructor(params: AiFileParams) { + super({ + ...params, + fileContent: params.fileContent ?? smolToml.stringify({}), + }); + this.toml = parseVibeConfig(this.fileContent); + } + + getToml(): VibeConfig { + return this.toml; + } + + override isDeletable(): boolean { + return false; + } + + static getSettablePaths(_options: { global?: boolean } = {}): ToolPermissionsSettablePaths { + return { + relativeDirPath: ".vibe", + relativeFilePath: "config.toml", + }; + } + + static async fromFile({ + outputRoot = process.cwd(), + validate = true, + global = false, + }: ToolPermissionsFromFileParams): Promise { + const paths = this.getSettablePaths({ global }); + const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath); + const fileContent = (await readFileContentOrNull(filePath)) ?? smolToml.stringify({}); + + return new VibePermissions({ + outputRoot, + relativeDirPath: paths.relativeDirPath, + relativeFilePath: paths.relativeFilePath, + fileContent, + validate, + global, + }); + } + + static async fromRulesyncPermissions({ + outputRoot = process.cwd(), + rulesyncPermissions, + validate = true, + logger, + global = false, + }: ToolPermissionsFromRulesyncPermissionsParams): Promise { + const paths = this.getSettablePaths({ global }); + const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath); + const existingContent = (await readFileContentOrNull(filePath)) ?? smolToml.stringify({}); + const config = parseVibeConfig(existingContent); + + const tools = toVibeToolsRecord(config.tools); + const disabledTools = new Set(toStringArray(config.disabled_tools)); + + for (const [category, rules] of Object.entries(rulesyncPermissions.getJson().permission)) { + const vibeToolName = toVibeToolName(category); + const existingTool = toVibeToolConfig(tools[vibeToolName]); + const nextTool: VibeToolConfig = { ...existingTool }; + const allow = new Set(toStringArray(existingTool.allow ?? existingTool.allowlist)); + const deny = new Set(toStringArray(existingTool.deny ?? existingTool.denylist)); + + for (const [pattern, action] of Object.entries(rules)) { + if (pattern === "*") { + applyWildcardPermission({ action, toolConfig: nextTool }); + if (action === "deny") { + disabledTools.add(vibeToolName); + } + continue; + } + + if (action === "ask") { + logger?.warn( + `Vibe permissions do not support pattern-level "ask" rules. Skipping ${category}: ${pattern}`, + ); + continue; + } + + if (action === "allow") { + allow.add(pattern); + } else { + deny.add(pattern); + } + } + + if (allow.size > 0) { + nextTool.allow = [...allow].toSorted(); + } + if (deny.size > 0) { + nextTool.deny = [...deny].toSorted(); + } + tools[vibeToolName] = nextTool; + } + + config.tools = tools; + if (disabledTools.size > 0) { + config.disabled_tools = [...disabledTools].toSorted(); + } + + return new VibePermissions({ + outputRoot, + relativeDirPath: paths.relativeDirPath, + relativeFilePath: paths.relativeFilePath, + fileContent: smolToml.stringify(config), + validate, + global, + }); + } + + toRulesyncPermissions(): RulesyncPermissions { + const permission: PermissionsConfig["permission"] = {}; + + for (const tool of toStringArray(this.toml.enabled_tools)) { + ensurePermission(permission, toCanonicalToolName(tool))["*"] = "allow"; + } + for (const tool of toStringArray(this.toml.disabled_tools)) { + ensurePermission(permission, toCanonicalToolName(tool))["*"] = "deny"; + } + + for (const [vibeToolName, toolConfig] of Object.entries(toVibeToolsRecord(this.toml.tools))) { + const category = toCanonicalToolName(vibeToolName); + const rules = ensurePermission(permission, category); + const action = fromVibePermission(toolConfig.permission); + if (action !== undefined) { + rules["*"] = action; + } + for (const pattern of toStringArray(toolConfig.allow ?? toolConfig.allowlist)) { + rules[pattern] = "allow"; + } + for (const pattern of toStringArray(toolConfig.deny ?? toolConfig.denylist)) { + rules[pattern] = "deny"; + } + } + + return this.toRulesyncPermissionsDefault({ + fileContent: JSON.stringify({ permission }, null, 2), + }); + } + + validate(): ValidationResult { + try { + parseVibeConfig(this.fileContent); + return { success: true, error: null }; + } catch (error) { + return { + success: false, + error: new Error(`Failed to parse Vibe permissions TOML: ${formatError(error)}`), + }; + } + } + + static forDeletion({ + outputRoot = process.cwd(), + relativeDirPath, + relativeFilePath, + global = false, + }: ToolPermissionsForDeletionParams): VibePermissions { + return new VibePermissions({ + outputRoot, + relativeDirPath, + relativeFilePath, + fileContent: smolToml.stringify({}), + validate: false, + global, + }); + } +} + +function parseVibeConfig(fileContent: string): VibeConfig { + const parsed = smolToml.parse(fileContent || smolToml.stringify({})); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return {}; + } + return { ...(parsed as Record) }; +} + +function toVibeToolName(category: string): string { + return CANONICAL_TO_VIBE_TOOL_NAMES[category] ?? category; +} + +function toCanonicalToolName(vibeToolName: string): string { + return VIBE_TO_CANONICAL_TOOL_NAMES[vibeToolName] ?? vibeToolName; +} + +function toVibeToolsRecord(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return {}; + } + return Object.fromEntries( + Object.entries(value as Record).map(([toolName, config]) => [ + toolName, + toVibeToolConfig(config), + ]), + ); +} + +function toVibeToolConfig(value: unknown): VibeToolConfig { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return {}; + } + return { ...(value as Record) }; +} + +function toStringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === "string") + : []; +} + +function applyWildcardPermission({ + action, + toolConfig, +}: { + action: PermissionAction; + toolConfig: VibeToolConfig; +}): void { + if (action === "allow") { + toolConfig.permission = "always"; + } else if (action === "ask") { + toolConfig.permission = "ask"; + } else if (toolConfig.permission !== undefined) { + delete toolConfig.permission; + } +} + +function fromVibePermission(value: unknown): PermissionAction | undefined { + if (value === "always" || value === "allow") { + return "allow"; + } + if (value === "ask") { + return "ask"; + } + if (value === "deny" || value === "never") { + return "deny"; + } + return undefined; +} + +function ensurePermission( + permission: PermissionsConfig["permission"], + category: string, +): Record { + const existing = permission[category]; + if (existing) { + return existing; + } + const created: Record = {}; + permission[category] = created; + return created; +} diff --git a/src/features/rules/rules-processor.test.ts b/src/features/rules/rules-processor.test.ts index ad7df509e..22732aaf8 100644 --- a/src/features/rules/rules-processor.test.ts +++ b/src/features/rules/rules-processor.test.ts @@ -910,6 +910,7 @@ Content that would fail parsing`; "pi", "rovodev", "takt", + "vibe", "devin", "zed", ]); @@ -952,9 +953,10 @@ Content that would fail parsing`; expect(globalTargets).toContain("pi"); expect(globalTargets).toContain("rovodev"); expect(globalTargets).toContain("takt"); + expect(globalTargets).toContain("vibe"); expect(globalTargets).toContain("devin"); expect(globalTargets).toContain("zed"); - expect(globalTargets.length).toBe(22); + expect(globalTargets.length).toBe(23); // These targets should NOT be in global mode expect(globalTargets).not.toContain("cursor"); diff --git a/src/features/rules/rules-processor.ts b/src/features/rules/rules-processor.ts index 95538be91..4097af35e 100644 --- a/src/features/rules/rules-processor.ts +++ b/src/features/rules/rules-processor.ts @@ -68,6 +68,7 @@ import { ToolRuleSettablePaths, ToolRuleSettablePathsGlobal, } from "./tool-rule.js"; +import { VibeRule } from "./vibe-rule.js"; import { WarpRule } from "./warp-rule.js"; import { ZedRule } from "./zed-rule.js"; @@ -100,6 +101,7 @@ const rulesProcessorToolTargets: ToolTarget[] = [ "roo", "rovodev", "takt", + "vibe", "warp", "devin", "zed", @@ -585,6 +587,20 @@ const toolRuleFactories = new Map([ }, }, ], + [ + "vibe", + { + class: VibeRule, + meta: { + // Vibe loads project AGENTS.md from the trusted working tree and + // user-level AGENTS.md from ~/.vibe/AGENTS.md. It does not have a + // native non-root rule directory. + extension: "md", + supportsGlobal: true, + ruleDiscoveryMode: "auto", + }, + }, + ], [ "warp", { diff --git a/src/features/rules/vibe-rule.test.ts b/src/features/rules/vibe-rule.test.ts new file mode 100644 index 000000000..c41ceefd7 --- /dev/null +++ b/src/features/rules/vibe-rule.test.ts @@ -0,0 +1,90 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { setupTestDirectory } from "../../test-utils/test-directories.js"; +import { RulesyncRule } from "./rulesync-rule.js"; +import { VibeRule } from "./vibe-rule.js"; + +describe("VibeRule", () => { + let testDir: string; + let cleanup: () => Promise; + + beforeEach(async () => { + ({ testDir, cleanup } = await setupTestDirectory()); + vi.spyOn(process, "cwd").mockReturnValue(testDir); + }); + + afterEach(async () => { + await cleanup(); + vi.restoreAllMocks(); + }); + + it("should write project root rules to ./AGENTS.md", () => { + const rulesyncRule = new RulesyncRule({ + outputRoot: testDir, + relativeDirPath: ".rulesync/rules", + relativeFilePath: "overview.md", + frontmatter: { root: true, targets: ["vibe"], description: "Project context" }, + body: "Use the project conventions.", + }); + + const vibeRule = VibeRule.fromRulesyncRule({ outputRoot: testDir, rulesyncRule }); + + expect(vibeRule.getRelativeDirPath()).toBe("."); + expect(vibeRule.getRelativeFilePath()).toBe("AGENTS.md"); + expect(vibeRule.getFileContent()).toBe("Use the project conventions."); + }); + + it("should write global root rules to .vibe/AGENTS.md", () => { + const rulesyncRule = new RulesyncRule({ + outputRoot: testDir, + relativeDirPath: ".rulesync/rules", + relativeFilePath: "overview.md", + frontmatter: { root: true, targets: ["vibe"] }, + body: "Global Vibe context.", + }); + + const vibeRule = VibeRule.fromRulesyncRule({ + outputRoot: testDir, + rulesyncRule, + global: true, + }); + + expect(vibeRule.getRelativeDirPath()).toBe(".vibe"); + expect(vibeRule.getRelativeFilePath()).toBe("AGENTS.md"); + }); + + it("should ignore non-root rules even when targeted to vibe", () => { + const rulesyncRule = new RulesyncRule({ + outputRoot: testDir, + relativeDirPath: ".rulesync/rules", + relativeFilePath: "detail.md", + frontmatter: { root: false, targets: ["vibe"] }, + body: "Non-root rule.", + }); + + expect(VibeRule.isTargetedByRulesyncRule(rulesyncRule)).toBe(false); + expect(() => VibeRule.fromRulesyncRule({ outputRoot: testDir, rulesyncRule })).toThrow( + /only supports root rules/, + ); + }); + + it("should target root rules with wildcard or vibe target", () => { + const wildcardRule = new RulesyncRule({ + outputRoot: testDir, + relativeDirPath: ".rulesync/rules", + relativeFilePath: "overview.md", + frontmatter: { root: true, targets: ["*"] }, + body: "Root rule.", + }); + const otherRule = new RulesyncRule({ + outputRoot: testDir, + relativeDirPath: ".rulesync/rules", + relativeFilePath: "overview.md", + frontmatter: { root: true, targets: ["zed"] }, + body: "Root rule.", + }); + + expect(VibeRule.isTargetedByRulesyncRule(wildcardRule)).toBe(true); + expect(VibeRule.isTargetedByRulesyncRule(otherRule)).toBe(false); + }); +}); diff --git a/src/features/rules/vibe-rule.ts b/src/features/rules/vibe-rule.ts new file mode 100644 index 000000000..253c55199 --- /dev/null +++ b/src/features/rules/vibe-rule.ts @@ -0,0 +1,136 @@ +import { join } from "node:path"; + +import { ValidationResult } from "../../types/ai-file.js"; +import { readFileContent } from "../../utils/file.js"; +import { RulesyncRule } from "./rulesync-rule.js"; +import { + ToolRule, + ToolRuleForDeletionParams, + ToolRuleFromFileParams, + ToolRuleFromRulesyncRuleParams, + ToolRuleSettablePaths, + ToolRuleSettablePathsGlobal, +} from "./tool-rule.js"; + +export type VibeRuleSettablePaths = Pick & { + root: { + relativeDirPath: string; + relativeFilePath: string; + }; + nonRoot?: undefined; +}; + +export type VibeRuleSettablePathsGlobal = ToolRuleSettablePathsGlobal; + +export class VibeRule extends ToolRule { + static getSettablePaths({ + global = false, + }: { + global?: boolean; + excludeToolDir?: boolean; + } = {}): VibeRuleSettablePaths | VibeRuleSettablePathsGlobal { + if (global) { + return { + root: { + relativeDirPath: ".vibe", + relativeFilePath: "AGENTS.md", + }, + }; + } + return { + root: { + relativeDirPath: ".", + relativeFilePath: "AGENTS.md", + }, + }; + } + + static async fromFile({ + outputRoot = process.cwd(), + relativeFilePath, + validate = true, + global = false, + }: ToolRuleFromFileParams): Promise { + const paths = this.getSettablePaths({ global }); + const isRoot = relativeFilePath === paths.root.relativeFilePath; + + if (!isRoot) { + throw new Error(`VibeRule only supports root rules: ${relativeFilePath}`); + } + + const fileContent = await readFileContent( + join(outputRoot, paths.root.relativeDirPath, paths.root.relativeFilePath), + ); + + return new VibeRule({ + outputRoot, + relativeDirPath: paths.root.relativeDirPath, + relativeFilePath: paths.root.relativeFilePath, + fileContent, + validate, + root: true, + }); + } + + static fromRulesyncRule({ + outputRoot = process.cwd(), + rulesyncRule, + validate = true, + global = false, + }: ToolRuleFromRulesyncRuleParams): VibeRule { + const paths = this.getSettablePaths({ global }); + const isRoot = rulesyncRule.getFrontmatter().root ?? false; + if (!isRoot) { + throw new Error(`VibeRule only supports root rules: ${rulesyncRule.getRelativeFilePath()}`); + } + + return new VibeRule( + this.buildToolRuleParamsDefault({ + outputRoot, + rulesyncRule, + validate, + rootPath: paths.root, + nonRootPath: undefined, + }), + ); + } + + toRulesyncRule(): RulesyncRule { + return this.toRulesyncRuleDefault(); + } + + validate(): ValidationResult { + return { success: true, error: null }; + } + + static forDeletion({ + outputRoot = process.cwd(), + relativeDirPath, + relativeFilePath, + global = false, + }: ToolRuleForDeletionParams): VibeRule { + const paths = this.getSettablePaths({ global }); + const isRoot = relativeFilePath === paths.root.relativeFilePath; + + return new VibeRule({ + outputRoot, + relativeDirPath, + relativeFilePath, + fileContent: "", + validate: false, + root: isRoot, + }); + } + + static isTargetedByRulesyncRule(rulesyncRule: RulesyncRule): boolean { + const isRoot = rulesyncRule.getFrontmatter().root ?? false; + if (!isRoot) { + return false; + } + + return this.isTargetedByRulesyncRuleDefault({ + rulesyncRule, + toolTarget: "vibe", + }); + } +} diff --git a/src/features/skills/rulesync-skill.ts b/src/features/skills/rulesync-skill.ts index b6a3d4c33..d63490035 100644 --- a/src/features/skills/rulesync-skill.ts +++ b/src/features/skills/rulesync-skill.ts @@ -138,6 +138,15 @@ const RulesyncSkillFrontmatterSchemaInternal = z.looseObject({ "allowed-tools": z.optional(z.union([z.string(), z.array(z.string())])), }), ), + vibe: z.optional( + z.looseObject({ + license: z.optional(z.string()), + compatibility: z.optional(z.union([z.string(), z.looseObject({})])), + metadata: z.optional(z.looseObject({})), + "user-invocable": z.optional(z.boolean()), + "allowed-tools": z.optional(z.union([z.string(), z.array(z.string())])), + }), + ), takt: z.optional( z.looseObject({ // Rename the emitted file stem (e.g. "test-skill.md" → "{name}.md"). @@ -238,6 +247,13 @@ export type RulesyncSkillFrontmatterInput = { metadata?: Record; "allowed-tools"?: string | string[]; }; + vibe?: { + license?: string; + compatibility?: string | Record; + metadata?: Record; + "user-invocable"?: boolean; + "allowed-tools"?: string | string[]; + }; takt?: { name?: string; extends?: string; diff --git a/src/features/skills/skills-processor.test.ts b/src/features/skills/skills-processor.test.ts index d6ede12f1..a8a13a380 100644 --- a/src/features/skills/skills-processor.test.ts +++ b/src/features/skills/skills-processor.test.ts @@ -877,6 +877,7 @@ Content that would fail parsing`; "roo", "rovodev", "takt", + "vibe", "warp", "devin", "zed", @@ -913,6 +914,7 @@ Content that would fail parsing`; "roo", "rovodev", "takt", + "vibe", "warp", "devin", "zed", @@ -948,6 +950,7 @@ Content that would fail parsing`; "roo", "rovodev", "takt", + "vibe", "warp", "devin", "zed", @@ -993,6 +996,7 @@ Content that would fail parsing`; "roo", "rovodev", "takt", + "vibe", "warp", "devin", "zed", @@ -1027,6 +1031,7 @@ Content that would fail parsing`; "roo", "rovodev", "takt", + "vibe", "warp", "devin", "zed", diff --git a/src/features/skills/skills-processor.ts b/src/features/skills/skills-processor.ts index 16d58f025..4f1dc6d24 100644 --- a/src/features/skills/skills-processor.ts +++ b/src/features/skills/skills-processor.ts @@ -45,6 +45,7 @@ import { ToolSkillSettablePaths, toolSkillSearchRoots, } from "./tool-skill.js"; +import { VibeSkill } from "./vibe-skill.js"; import { WarpSkill } from "./warp-skill.js"; import { ZedSkill } from "./zed-skill.js"; @@ -100,6 +101,7 @@ const skillsProcessorToolTargetTuple = [ "roo", "rovodev", "takt", + "vibe", "warp", "devin", "zed", @@ -306,6 +308,16 @@ const toolSkillFactories = new Map( meta: { supportsProject: true, supportsSimulated: false, supportsGlobal: true }, }, ], + [ + "vibe", + { + // Vibe follows the Agent Skills format and discovers project skills from + // `.vibe/skills/` and `.agents/skills/`, with user-level skills in + // `~/.vibe/skills/`. + class: VibeSkill, + meta: { supportsProject: true, supportsSimulated: false, supportsGlobal: true }, + }, + ], [ "warp", { diff --git a/src/features/skills/vibe-skill.test.ts b/src/features/skills/vibe-skill.test.ts new file mode 100644 index 000000000..89fc07fc5 --- /dev/null +++ b/src/features/skills/vibe-skill.test.ts @@ -0,0 +1,139 @@ +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { SKILL_FILE_NAME } from "../../constants/general.js"; +import { RULESYNC_SKILLS_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js"; +import { setupTestDirectory } from "../../test-utils/test-directories.js"; +import { ensureDir, writeFileContent } from "../../utils/file.js"; +import { RulesyncSkill } from "./rulesync-skill.js"; +import { VibeSkill, VibeSkillFrontmatterSchema } from "./vibe-skill.js"; + +describe("VibeSkill", () => { + let testDir: string; + let cleanup: () => Promise; + + beforeEach(async () => { + ({ testDir, cleanup } = await setupTestDirectory()); + vi.spyOn(process, "cwd").mockReturnValue(testDir); + }); + + afterEach(async () => { + await cleanup(); + vi.restoreAllMocks(); + }); + + it("should validate Vibe skill frontmatter fields", () => { + const result = VibeSkillFrontmatterSchema.parse({ + name: "review", + description: "Review changes", + license: "MIT", + compatibility: "Node 22+", + "user-invocable": true, + "allowed-tools": ["read_file", "grep"], + }); + + expect(result["user-invocable"]).toBe(true); + expect(result["allowed-tools"]).toEqual(["read_file", "grep"]); + }); + + it("should expose primary and fallback import roots", () => { + expect(VibeSkill.getSettablePaths()).toEqual({ + relativeDirPath: join(".vibe", "skills"), + alternativeSkillRoots: [join(".agents", "skills")], + }); + expect(VibeSkill.getSettablePaths({ global: true })).toEqual({ + relativeDirPath: join(".vibe", "skills"), + }); + }); + + it("should convert a rulesync skill to .vibe/skills//SKILL.md", () => { + const rulesyncSkill = new RulesyncSkill({ + outputRoot: testDir, + relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH, + dirName: "review", + frontmatter: { + name: "review", + description: "Review changes", + targets: ["vibe"], + vibe: { + license: "MIT", + compatibility: "Node 22+", + "user-invocable": true, + "allowed-tools": ["read_file"], + }, + }, + body: "Review the diff.", + validate: true, + }); + + const vibeSkill = VibeSkill.fromRulesyncSkill({ + outputRoot: testDir, + rulesyncSkill, + }); + + expect(vibeSkill.getRelativeDirPath()).toBe(join(".vibe", "skills")); + expect(vibeSkill.getDirName()).toBe("review"); + expect(vibeSkill.getFrontmatter()).toMatchObject({ + name: "review", + description: "Review changes", + license: "MIT", + "user-invocable": true, + "allowed-tools": ["read_file"], + }); + expect(vibeSkill.getBody()).toBe("Review the diff."); + }); + + it("should import a Vibe skill into rulesync with a vibe metadata section", () => { + const vibeSkill = new VibeSkill({ + outputRoot: testDir, + relativeDirPath: join(".vibe", "skills"), + dirName: "review", + frontmatter: { + name: "review", + description: "Review changes", + license: "MIT", + "user-invocable": true, + "allowed-tools": ["read_file"], + }, + body: "Review the diff.", + }); + + const rulesyncSkill = vibeSkill.toRulesyncSkill(); + + expect(rulesyncSkill.getRelativeDirPath()).toBe(RULESYNC_SKILLS_RELATIVE_DIR_PATH); + expect(rulesyncSkill.getFrontmatter()).toMatchObject({ + name: "review", + description: "Review changes", + targets: ["*"], + vibe: { + license: "MIT", + "user-invocable": true, + "allowed-tools": ["read_file"], + }, + }); + }); + + it("should load from .agents/skills import fallback when requested by the processor", async () => { + const skillDir = join(testDir, ".agents", "skills", "fallback"); + await ensureDir(skillDir); + await writeFileContent( + join(skillDir, SKILL_FILE_NAME), + `--- +name: fallback +description: Fallback skill +--- +Fallback body`, + ); + + const vibeSkill = await VibeSkill.fromDir({ + outputRoot: testDir, + relativeDirPath: join(".agents", "skills"), + dirName: "fallback", + }); + + expect(vibeSkill.getRelativeDirPath()).toBe(join(".agents", "skills")); + expect(vibeSkill.getFrontmatter().name).toBe("fallback"); + expect(vibeSkill.getBody()).toBe("Fallback body"); + }); +}); diff --git a/src/features/skills/vibe-skill.ts b/src/features/skills/vibe-skill.ts new file mode 100644 index 000000000..9c11ffebe --- /dev/null +++ b/src/features/skills/vibe-skill.ts @@ -0,0 +1,245 @@ +import { join } from "node:path"; + +import { z } from "zod/mini"; + +import { SKILL_FILE_NAME } from "../../constants/general.js"; +import { RULESYNC_SKILLS_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js"; +import { ValidationResult } from "../../types/ai-dir.js"; +import { formatError } from "../../utils/error.js"; +import { RulesyncSkill, RulesyncSkillFrontmatterInput, SkillFile } from "./rulesync-skill.js"; +import { + ToolSkill, + ToolSkillForDeletionParams, + ToolSkillFromDirParams, + ToolSkillFromRulesyncSkillParams, + ToolSkillSettablePaths, +} from "./tool-skill.js"; + +export const VibeSkillFrontmatterSchema = z.looseObject({ + name: z.string(), + description: z.string(), + license: z.optional(z.string()), + compatibility: z.optional(z.union([z.string(), z.looseObject({})])), + metadata: z.optional(z.looseObject({})), + "user-invocable": z.optional(z.boolean()), + "allowed-tools": z.optional(z.union([z.string(), z.array(z.string())])), +}); + +export type VibeSkillFrontmatter = z.infer; + +export type VibeSkillParams = { + outputRoot?: string; + relativeDirPath?: string; + dirName: string; + frontmatter: VibeSkillFrontmatter; + body: string; + otherFiles?: SkillFile[]; + validate?: boolean; + global?: boolean; +}; + +export class VibeSkill extends ToolSkill { + constructor({ + outputRoot = process.cwd(), + relativeDirPath = VibeSkill.getSettablePaths().relativeDirPath, + dirName, + frontmatter, + body, + otherFiles = [], + validate = true, + global = false, + }: VibeSkillParams) { + super({ + outputRoot, + relativeDirPath, + dirName, + mainFile: { + name: SKILL_FILE_NAME, + body, + frontmatter: { ...frontmatter }, + }, + otherFiles, + global, + }); + + if (validate) { + const result = this.validate(); + if (!result.success) { + throw result.error; + } + } + } + + static getSettablePaths({ global = false }: { global?: boolean } = {}): ToolSkillSettablePaths { + return { + relativeDirPath: join(".vibe", "skills"), + ...(global ? {} : { alternativeSkillRoots: [join(".agents", "skills")] }), + }; + } + + getFrontmatter(): VibeSkillFrontmatter { + return VibeSkillFrontmatterSchema.parse(this.requireMainFileFrontmatter()); + } + + getBody(): string { + return this.mainFile?.body ?? ""; + } + + validate(): ValidationResult { + if (!this.mainFile) { + return { + success: false, + error: new Error(`${this.getDirPath()}: ${SKILL_FILE_NAME} file does not exist`), + }; + } + + const result = VibeSkillFrontmatterSchema.safeParse(this.mainFile.frontmatter); + if (!result.success) { + return { + success: false, + error: new Error( + `Invalid frontmatter in ${this.getDirPath()}: ${formatError(result.error)}`, + ), + }; + } + + return { success: true, error: null }; + } + + toRulesyncSkill(): RulesyncSkill { + const frontmatter = this.getFrontmatter(); + const vibeSection = { + ...(frontmatter.license !== undefined && { license: frontmatter.license }), + ...(frontmatter.compatibility !== undefined && { compatibility: frontmatter.compatibility }), + ...(frontmatter.metadata !== undefined && { metadata: frontmatter.metadata }), + ...(frontmatter["user-invocable"] !== undefined && { + "user-invocable": frontmatter["user-invocable"], + }), + ...(frontmatter["allowed-tools"] !== undefined && { + "allowed-tools": frontmatter["allowed-tools"], + }), + }; + const rulesyncFrontmatter: RulesyncSkillFrontmatterInput = { + name: frontmatter.name, + description: frontmatter.description, + targets: ["*"], + ...(Object.keys(vibeSection).length > 0 && { vibe: vibeSection }), + }; + + return new RulesyncSkill({ + outputRoot: this.outputRoot, + relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH, + dirName: this.getDirName(), + frontmatter: rulesyncFrontmatter, + body: this.getBody(), + otherFiles: this.getOtherFiles(), + validate: true, + global: this.global, + }); + } + + static fromRulesyncSkill({ + outputRoot = process.cwd(), + rulesyncSkill, + validate = true, + global = false, + }: ToolSkillFromRulesyncSkillParams): VibeSkill { + const settablePaths = VibeSkill.getSettablePaths({ global }); + const rulesyncFrontmatter = rulesyncSkill.getFrontmatter(); + const vibeSection = rulesyncFrontmatter.vibe; + + const looseTopLevel = rulesyncFrontmatter as Record; + const topLevelLicense = + typeof looseTopLevel.license === "string" ? looseTopLevel.license : undefined; + const topLevelCompatibility = + typeof looseTopLevel.compatibility === "string" || + (typeof looseTopLevel.compatibility === "object" && looseTopLevel.compatibility !== null) + ? (looseTopLevel.compatibility as string | Record) + : undefined; + const topLevelMetadata = + typeof looseTopLevel.metadata === "object" && looseTopLevel.metadata !== null + ? (looseTopLevel.metadata as Record) + : undefined; + + const vibeFrontmatter: VibeSkillFrontmatter = { + name: rulesyncFrontmatter.name, + description: rulesyncFrontmatter.description, + ...(vibeSection?.license !== undefined || topLevelLicense !== undefined + ? { license: vibeSection?.license ?? topLevelLicense } + : {}), + ...(vibeSection?.compatibility !== undefined || topLevelCompatibility !== undefined + ? { compatibility: vibeSection?.compatibility ?? topLevelCompatibility } + : {}), + ...(vibeSection?.metadata !== undefined || topLevelMetadata !== undefined + ? { metadata: vibeSection?.metadata ?? topLevelMetadata } + : {}), + ...(vibeSection?.["user-invocable"] !== undefined && { + "user-invocable": vibeSection["user-invocable"], + }), + ...(vibeSection?.["allowed-tools"] !== undefined && { + "allowed-tools": vibeSection["allowed-tools"], + }), + }; + + return new VibeSkill({ + outputRoot, + relativeDirPath: settablePaths.relativeDirPath, + dirName: rulesyncSkill.getDirName(), + frontmatter: vibeFrontmatter, + body: rulesyncSkill.getBody(), + otherFiles: rulesyncSkill.getOtherFiles(), + validate, + global, + }); + } + + static isTargetedByRulesyncSkill(rulesyncSkill: RulesyncSkill): boolean { + const targets = rulesyncSkill.getFrontmatter().targets; + return targets.includes("*") || targets.includes("vibe"); + } + + static async fromDir(params: ToolSkillFromDirParams): Promise { + const loaded = await this.loadSkillDirContent({ + ...params, + getSettablePaths: VibeSkill.getSettablePaths, + }); + + const result = VibeSkillFrontmatterSchema.safeParse(loaded.frontmatter); + if (!result.success) { + const skillDirPath = join(loaded.outputRoot, loaded.relativeDirPath, loaded.dirName); + throw new Error( + `Invalid frontmatter in ${join(skillDirPath, SKILL_FILE_NAME)}: ${formatError(result.error)}`, + ); + } + + return new VibeSkill({ + outputRoot: loaded.outputRoot, + relativeDirPath: loaded.relativeDirPath, + dirName: loaded.dirName, + frontmatter: result.data, + body: loaded.body, + otherFiles: loaded.otherFiles, + validate: true, + global: loaded.global, + }); + } + + static forDeletion({ + outputRoot = process.cwd(), + relativeDirPath, + dirName, + global = false, + }: ToolSkillForDeletionParams): VibeSkill { + const settablePaths = VibeSkill.getSettablePaths({ global }); + return new VibeSkill({ + outputRoot, + relativeDirPath: relativeDirPath ?? settablePaths.relativeDirPath, + dirName, + frontmatter: { name: "", description: "" }, + body: "", + otherFiles: [], + validate: false, + global, + }); + } +} diff --git a/src/features/subagents/rulesync-subagent.ts b/src/features/subagents/rulesync-subagent.ts index f05b31cde..413b8e5ae 100644 --- a/src/features/subagents/rulesync-subagent.ts +++ b/src/features/subagents/rulesync-subagent.ts @@ -25,6 +25,22 @@ export const RulesyncSubagentFrontmatterSchema = z.looseObject({ name: z.optional(z.string()), }), ), + vibe: z.optional( + z.looseObject({ + agent_type: z.optional(z.enum(["agent", "subagent"])), + display_name: z.optional(z.string()), + description: z.optional(z.string()), + safety: z.optional(z.string()), + active_model: z.optional(z.string()), + system_prompt: z.optional(z.string()), + system_prompt_id: z.optional(z.string()), + compaction_prompt: z.optional(z.string()), + compaction_prompt_id: z.optional(z.string()), + enabled_tools: z.optional(z.array(z.string())), + disabled_tools: z.optional(z.array(z.string())), + tools: z.optional(z.record(z.string(), z.looseObject({}))), + }), + ), }); // Input type allows targets to be omitted (will use default value) diff --git a/src/features/subagents/subagents-processor.test.ts b/src/features/subagents/subagents-processor.test.ts index db2f26847..200b8fcef 100644 --- a/src/features/subagents/subagents-processor.test.ts +++ b/src/features/subagents/subagents-processor.test.ts @@ -971,6 +971,7 @@ Second global content`; "opencode", "rovodev", "takt", + "vibe", ]); }); @@ -1019,6 +1020,7 @@ Second global content`; "roo", "rovodev", "takt", + "vibe", ]), ); expect(Array.isArray(subagentsProcessorToolTargets)).toBe(true); diff --git a/src/features/subagents/subagents-processor.ts b/src/features/subagents/subagents-processor.ts index 2cf2d0be7..91def6c03 100644 --- a/src/features/subagents/subagents-processor.ts +++ b/src/features/subagents/subagents-processor.ts @@ -36,6 +36,7 @@ import { ToolSubagentFromRulesyncSubagentParams, ToolSubagentSettablePaths, } from "./tool-subagent.js"; +import { VibeSubagent } from "./vibe-subagent.js"; /** * Factory entry for each tool subagent class. @@ -83,6 +84,7 @@ const subagentsProcessorToolTargetTuple = [ "roo", "rovodev", "takt", + "vibe", ] as const; export type SubagentsProcessorToolTarget = (typeof subagentsProcessorToolTargetTuple)[number]; @@ -251,6 +253,13 @@ const toolSubagentFactories = new Map { + let testDir: string; + let cleanup: () => Promise; + + beforeEach(async () => { + ({ testDir, cleanup } = await setupTestDirectory()); + vi.spyOn(process, "cwd").mockReturnValue(testDir); + }); + + afterEach(async () => { + await cleanup(); + vi.restoreAllMocks(); + }); + + it("should validate Vibe agent and subagent TOML", () => { + expect(() => + VibeSubagentTomlSchema.parse({ + agent_type: "agent", + display_name: "Red team", + safety: "safe", + }), + ).not.toThrow(); + expect(() => VibeSubagentTomlSchema.parse({ display_name: "Missing type" })).toThrow(); + }); + + it("should export rulesync subagents as Vibe subagents by default", () => { + const rulesyncSubagent = new RulesyncSubagent({ + outputRoot: testDir, + relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, + relativeFilePath: "research.md", + frontmatter: { + targets: ["vibe"], + name: "Research", + description: "Research agent", + vibe: { + safety: "safe", + enabled_tools: ["grep", "read_file"], + }, + }, + body: "Research the codebase.", + }); + + const vibeSubagent = VibeSubagent.fromRulesyncSubagent({ + outputRoot: testDir, + relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, + rulesyncSubagent, + }) as VibeSubagent; + const parsed = smolToml.parse(vibeSubagent.getBody()) as any; + + expect(vibeSubagent.getRelativeDirPath()).toBe(join(".vibe", "agents")); + expect(vibeSubagent.getRelativeFilePath()).toBe("research.toml"); + expect(parsed).toMatchObject({ + agent_type: "subagent", + display_name: "Research", + description: "Research agent", + safety: "safe", + enabled_tools: ["grep", "read_file"], + system_prompt: "Research the codebase.", + }); + }); + + it("should preserve explicit Vibe agent_type agent", () => { + const rulesyncSubagent = new RulesyncSubagent({ + outputRoot: testDir, + relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, + relativeFilePath: "redteam.md", + frontmatter: { + targets: ["vibe"], + name: "Red team", + description: "Security review agent", + vibe: { + agent_type: "agent", + active_model: "mistral-medium-latest", + disabled_tools: ["write_file"], + tools: { bash: { permission: "ask" } }, + }, + }, + body: "Review for security issues.", + }); + + const vibeSubagent = VibeSubagent.fromRulesyncSubagent({ + outputRoot: testDir, + relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, + rulesyncSubagent, + }) as VibeSubagent; + const parsed = smolToml.parse(vibeSubagent.getBody()) as any; + + expect(parsed.agent_type).toBe("agent"); + expect(parsed.active_model).toBe("mistral-medium-latest"); + expect(parsed.disabled_tools).toEqual(["write_file"]); + expect(parsed.tools.bash.permission).toBe("ask"); + }); + + it("should import Vibe TOML agents into rulesync subagents with a vibe section", () => { + const toml = [ + 'agent_type = "agent"', + 'display_name = "Red team"', + 'description = "Security review agent"', + 'safety = "safe"', + 'system_prompt = "Review for security issues."', + 'disabled_tools = ["write_file"]', + "", + "[tools.bash]", + 'permission = "ask"', + ].join("\n"); + + const vibeSubagent = new VibeSubagent({ + outputRoot: testDir, + relativeDirPath: join(".vibe", "agents"), + relativeFilePath: "redteam.toml", + body: toml, + fileContent: toml, + }); + + const rulesyncSubagent = vibeSubagent.toRulesyncSubagent(); + + expect(rulesyncSubagent.getRelativeFilePath()).toBe("redteam.md"); + expect(rulesyncSubagent.getBody()).toBe("Review for security issues."); + expect(rulesyncSubagent.getFrontmatter()).toMatchObject({ + targets: ["vibe"], + name: "Red team", + description: "Security review agent", + vibe: { + agent_type: "agent", + display_name: "Red team", + safety: "safe", + disabled_tools: ["write_file"], + tools: { bash: { permission: "ask" } }, + }, + }); + }); +}); diff --git a/src/features/subagents/vibe-subagent.ts b/src/features/subagents/vibe-subagent.ts new file mode 100644 index 000000000..88e9b8f5d --- /dev/null +++ b/src/features/subagents/vibe-subagent.ts @@ -0,0 +1,207 @@ +import { basename, join } from "node:path"; + +import * as smolToml from "smol-toml"; +import { z } from "zod/mini"; + +import { RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js"; +import { AiFileParams, ValidationResult } from "../../types/ai-file.js"; +import { formatError } from "../../utils/error.js"; +import { readFileContent } from "../../utils/file.js"; +import { RulesyncSubagent, RulesyncSubagentFrontmatter } from "./rulesync-subagent.js"; +import { + ToolSubagent, + ToolSubagentForDeletionParams, + ToolSubagentFromFileParams, + ToolSubagentFromRulesyncSubagentParams, + ToolSubagentSettablePaths, +} from "./tool-subagent.js"; + +export const VibeSubagentTomlSchema = z.looseObject({ + agent_type: z.enum(["agent", "subagent"]), + display_name: z.optional(z.string()), + description: z.optional(z.string()), + safety: z.optional(z.string()), + active_model: z.optional(z.string()), + system_prompt: z.optional(z.string()), + system_prompt_id: z.optional(z.string()), + compaction_prompt: z.optional(z.string()), + compaction_prompt_id: z.optional(z.string()), + enabled_tools: z.optional(z.array(z.string())), + disabled_tools: z.optional(z.array(z.string())), + tools: z.optional(z.record(z.string(), z.looseObject({}))), +}); + +type VibeSubagentToml = z.infer; + +export type VibeSubagentParams = { + body: string; +} & AiFileParams; + +export class VibeSubagent extends ToolSubagent { + private readonly body: string; + + constructor({ body, ...rest }: VibeSubagentParams) { + if (rest.validate !== false) { + try { + VibeSubagentTomlSchema.parse(smolToml.parse(body)); + } catch (error) { + throw new Error( + `Invalid TOML in ${join(rest.relativeDirPath, rest.relativeFilePath)}: ${formatError(error)}`, + { cause: error }, + ); + } + } + + super({ ...rest }); + this.body = body; + } + + static getSettablePaths(_options: { global?: boolean } = {}): ToolSubagentSettablePaths { + return { + relativeDirPath: join(".vibe", "agents"), + }; + } + + getBody(): string { + return this.body; + } + + toRulesyncSubagent(): RulesyncSubagent { + let parsed: VibeSubagentToml; + try { + parsed = VibeSubagentTomlSchema.parse(smolToml.parse(this.body)); + } catch (error) { + throw new Error( + `Failed to parse TOML in ${join(this.getRelativeDirPath(), this.getRelativeFilePath())}: ${formatError(error)}`, + { cause: error }, + ); + } + + const { system_prompt, description, display_name, ...vibeSection } = parsed; + const fileStem = basename(this.getRelativeFilePath(), ".toml"); + const rulesyncFrontmatter: RulesyncSubagentFrontmatter = { + targets: ["vibe"], + name: display_name ?? fileStem, + ...(description !== undefined && { description }), + vibe: { + ...(display_name !== undefined && { display_name }), + ...(description !== undefined && { description }), + ...vibeSection, + }, + }; + + return new RulesyncSubagent({ + outputRoot: this.outputRoot, + frontmatter: rulesyncFrontmatter, + body: system_prompt ?? "", + relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, + relativeFilePath: this.getRelativeFilePath().replace(/\.toml$/, ".md"), + validate: true, + }); + } + + static fromRulesyncSubagent({ + outputRoot = process.cwd(), + rulesyncSubagent, + validate = true, + global = false, + }: ToolSubagentFromRulesyncSubagentParams): ToolSubagent { + const frontmatter = rulesyncSubagent.getFrontmatter(); + const rawSection: Record = frontmatter.vibe ?? {}; + const vibeSection = this.filterToolSpecificSection(rawSection, [ + "agent_type", + "display_name", + "description", + "system_prompt", + ]); + + const tomlObj: VibeSubagentToml = { + agent_type: rawSection.agent_type === "agent" ? "agent" : "subagent", + display_name: + typeof rawSection.display_name === "string" ? rawSection.display_name : frontmatter.name, + ...(frontmatter.description ? { description: frontmatter.description } : {}), + ...(rulesyncSubagent.getBody() ? { system_prompt: rulesyncSubagent.getBody() } : {}), + ...vibeSection, + }; + + const body = smolToml.stringify(tomlObj); + const paths = this.getSettablePaths({ global }); + const relativeFilePath = rulesyncSubagent.getRelativeFilePath().replace(/\.md$/, ".toml"); + + return new VibeSubagent({ + outputRoot, + body, + relativeDirPath: paths.relativeDirPath, + relativeFilePath, + fileContent: body, + validate, + global, + }); + } + + validate(): ValidationResult { + try { + VibeSubagentTomlSchema.parse(smolToml.parse(this.body)); + return { success: true, error: null }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error : new Error(String(error)), + }; + } + } + + static isTargetedByRulesyncSubagent(rulesyncSubagent: RulesyncSubagent): boolean { + return this.isTargetedByRulesyncSubagentDefault({ + rulesyncSubagent, + toolTarget: "vibe", + }); + } + + static async fromFile({ + outputRoot = process.cwd(), + relativeFilePath, + validate = true, + global = false, + }: ToolSubagentFromFileParams): Promise { + const paths = this.getSettablePaths({ global }); + const filePath = join(outputRoot, paths.relativeDirPath, relativeFilePath); + const fileContent = await readFileContent(filePath); + + const subagent = new VibeSubagent({ + outputRoot, + relativeDirPath: paths.relativeDirPath, + relativeFilePath, + body: fileContent.trim(), + fileContent, + validate, + global, + }); + + if (validate) { + const result = subagent.validate(); + if (!result.success) { + throw new Error(`Invalid TOML in ${filePath}: ${formatError(result.error)}`); + } + } + + return subagent; + } + + static forDeletion({ + outputRoot = process.cwd(), + relativeDirPath, + relativeFilePath, + global = false, + }: ToolSubagentForDeletionParams): VibeSubagent { + return new VibeSubagent({ + outputRoot, + relativeDirPath, + relativeFilePath, + body: "", + fileContent: "", + validate: false, + global, + }); + } +} diff --git a/src/types/tool-targets.test.ts b/src/types/tool-targets.test.ts index 85eb80186..fe295a446 100644 --- a/src/types/tool-targets.test.ts +++ b/src/types/tool-targets.test.ts @@ -41,6 +41,7 @@ describe("tool targets", () => { "roo", "rovodev", "takt", + "vibe", "warp", "devin", "zed", diff --git a/src/types/tool-targets.ts b/src/types/tool-targets.ts index fbad04e52..f5fa9dbe8 100644 --- a/src/types/tool-targets.ts +++ b/src/types/tool-targets.ts @@ -33,6 +33,7 @@ export const ALL_TOOL_TARGETS = [ "roo", "rovodev", "takt", + "vibe", "warp", "devin", "zed", From 7c6626d582c9e8057fb69d17f0f9c4c4a0d97f68 Mon Sep 17 00:00:00 2001 From: sirmacik <127441966+sirmacik@users.noreply.github.com> Date: Sat, 13 Jun 2026 20:38:11 +0000 Subject: [PATCH 2/3] fix: reconcile stale Vibe enabled/disabled tool filters on regenerate VibePermissions.fromRulesyncPermissions only added new denies to disabled_tools and never touched enabled_tools, so a prior .vibe/config.toml could keep a stale filter that overrode the current rulesync permissions (e.g. disabled_tools = ["write_file"] surviving a later edit/write -> allow, or a stale enabled_tools allow-list). Clear both enabled_tools and disabled_tools for every tool the current rulesync permissions own before reapplying state, while preserving filters for tools rulesync does not configure. Add regression tests for deny -> allow, stale enabled_tools deny, and unowned-filter preservation. Co-Authored-By: Claude Opus 4.8 --- .../permissions/vibe-permissions.test.ts | 83 +++++++++++++++++++ src/features/permissions/vibe-permissions.ts | 26 +++++- 2 files changed, 108 insertions(+), 1 deletion(-) diff --git a/src/features/permissions/vibe-permissions.test.ts b/src/features/permissions/vibe-permissions.test.ts index 4bc91a4c9..810a0bd76 100644 --- a/src/features/permissions/vibe-permissions.test.ts +++ b/src/features/permissions/vibe-permissions.test.ts @@ -138,4 +138,87 @@ describe("VibePermissions", () => { expect(parsed.tools.write_file.allow).toEqual(["*.md", "*.txt"]); }); + + it("should clear a stale disabled_tools entry when rulesync now allows the tool", async () => { + await ensureDir(join(testDir, ".vibe")); + await writeFileContent( + join(testDir, ".vibe", "config.toml"), + 'disabled_tools = ["write_file"]', + ); + + const rulesyncPermissions = new RulesyncPermissions({ + outputRoot: testDir, + relativeDirPath: ".rulesync", + relativeFilePath: "permissions.json", + fileContent: JSON.stringify({ + permission: { + edit: { "*": "allow" }, + }, + }), + }); + + const vibePermissions = await VibePermissions.fromRulesyncPermissions({ + outputRoot: testDir, + rulesyncPermissions, + }); + const parsed = smolToml.parse(vibePermissions.getFileContent()) as any; + + // The stale deny filter must not survive the new "allow" source of truth. + expect(parsed.disabled_tools).toBeUndefined(); + expect(parsed.enabled_tools).toEqual(["write_file"]); + expect(parsed.tools.write_file.permission).toBe("always"); + }); + + it("should clear a stale enabled_tools entry when rulesync now denies the tool", async () => { + await ensureDir(join(testDir, ".vibe")); + await writeFileContent(join(testDir, ".vibe", "config.toml"), 'enabled_tools = ["read_file"]'); + + const rulesyncPermissions = new RulesyncPermissions({ + outputRoot: testDir, + relativeDirPath: ".rulesync", + relativeFilePath: "permissions.json", + fileContent: JSON.stringify({ + permission: { + read: { "*": "deny" }, + }, + }), + }); + + const vibePermissions = await VibePermissions.fromRulesyncPermissions({ + outputRoot: testDir, + rulesyncPermissions, + }); + const parsed = smolToml.parse(vibePermissions.getFileContent()) as any; + + expect(parsed.enabled_tools).toBeUndefined(); + expect(parsed.disabled_tools).toEqual(["read_file"]); + }); + + it("should preserve enabled/disabled filters for tools rulesync does not configure", async () => { + await ensureDir(join(testDir, ".vibe")); + await writeFileContent( + join(testDir, ".vibe", "config.toml"), + ['enabled_tools = ["custom_tool"]', 'disabled_tools = ["other_tool"]'].join("\n"), + ); + + const rulesyncPermissions = new RulesyncPermissions({ + outputRoot: testDir, + relativeDirPath: ".rulesync", + relativeFilePath: "permissions.json", + fileContent: JSON.stringify({ + permission: { + edit: { "*": "deny" }, + }, + }), + }); + + const vibePermissions = await VibePermissions.fromRulesyncPermissions({ + outputRoot: testDir, + rulesyncPermissions, + }); + const parsed = smolToml.parse(vibePermissions.getFileContent()) as any; + + expect(parsed.enabled_tools).toEqual(["custom_tool"]); + expect(parsed.disabled_tools).toEqual(["other_tool", "write_file"]); + }); }); diff --git a/src/features/permissions/vibe-permissions.ts b/src/features/permissions/vibe-permissions.ts index 9e0c58450..afc179749 100644 --- a/src/features/permissions/vibe-permissions.ts +++ b/src/features/permissions/vibe-permissions.ts @@ -103,10 +103,23 @@ export class VibePermissions extends ToolPermissions { const existingContent = (await readFileContentOrNull(filePath)) ?? smolToml.stringify({}); const config = parseVibeConfig(existingContent); + const permission = rulesyncPermissions.getJson().permission; + const tools = toVibeToolsRecord(config.tools); + const enabledTools = new Set(toStringArray(config.enabled_tools)); const disabledTools = new Set(toStringArray(config.disabled_tools)); - for (const [category, rules] of Object.entries(rulesyncPermissions.getJson().permission)) { + // rulesync is the source of truth for every tool it configures, so drop any + // stale enabled/disabled filters for those tools before reapplying the + // current state. Filters for tools rulesync does not configure are kept as-is + // (e.g. a user-defined `enabled_tools` entry for a Vibe-only tool). + for (const category of Object.keys(permission)) { + const vibeToolName = toVibeToolName(category); + enabledTools.delete(vibeToolName); + disabledTools.delete(vibeToolName); + } + + for (const [category, rules] of Object.entries(permission)) { const vibeToolName = toVibeToolName(category); const existingTool = toVibeToolConfig(tools[vibeToolName]); const nextTool: VibeToolConfig = { ...existingTool }; @@ -118,6 +131,10 @@ export class VibePermissions extends ToolPermissions { applyWildcardPermission({ action, toolConfig: nextTool }); if (action === "deny") { disabledTools.add(vibeToolName); + enabledTools.delete(vibeToolName); + } else if (action === "allow") { + enabledTools.add(vibeToolName); + disabledTools.delete(vibeToolName); } continue; } @@ -146,8 +163,15 @@ export class VibePermissions extends ToolPermissions { } config.tools = tools; + if (enabledTools.size > 0) { + config.enabled_tools = [...enabledTools].toSorted(); + } else { + delete config.enabled_tools; + } if (disabledTools.size > 0) { config.disabled_tools = [...disabledTools].toSorted(); + } else { + delete config.disabled_tools; } return new VibePermissions({ From f0264e4cfeabb4084243dc1d7a5be43b09e7c6eb Mon Sep 17 00:00:00 2001 From: sirmacik <127441966+sirmacik@users.noreply.github.com> Date: Sat, 13 Jun 2026 20:38:12 +0000 Subject: [PATCH 3/3] chore: satisfy CSpell for Vibe terms and rename subagent test fixture CI "Code Quality & Tests" failed on CSpell unknown words introduced by the Vibe target. Add `vibeignore` (the .vibeignore filename) and `devstral` (a Mistral model used in an MCP test fixture) to cspell.json, and rename the subagent test fixture redteam.{md,toml} to security-reviewer.{md,toml} so the file name is self-describing. Co-Authored-By: Claude Opus 4.8 --- cspell.json | 2 ++ src/features/subagents/vibe-subagent.test.ts | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/cspell.json b/cspell.json index ea4c8b787..b1a0f7177 100644 --- a/cspell.json +++ b/cspell.json @@ -84,6 +84,7 @@ "deepwiki", "depd", "devinignore", + "devstral", "dnsutils", "donotpresent", "dotenv", @@ -303,6 +304,7 @@ "USERPROFILE", "uvicorn", "venv", + "vibeignore", "vite", "vuepress", "weakmap", diff --git a/src/features/subagents/vibe-subagent.test.ts b/src/features/subagents/vibe-subagent.test.ts index 69b5de873..7ac4a1cd3 100644 --- a/src/features/subagents/vibe-subagent.test.ts +++ b/src/features/subagents/vibe-subagent.test.ts @@ -73,7 +73,7 @@ describe("VibeSubagent", () => { const rulesyncSubagent = new RulesyncSubagent({ outputRoot: testDir, relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, - relativeFilePath: "redteam.md", + relativeFilePath: "security-reviewer.md", frontmatter: { targets: ["vibe"], name: "Red team", @@ -117,14 +117,14 @@ describe("VibeSubagent", () => { const vibeSubagent = new VibeSubagent({ outputRoot: testDir, relativeDirPath: join(".vibe", "agents"), - relativeFilePath: "redteam.toml", + relativeFilePath: "security-reviewer.toml", body: toml, fileContent: toml, }); const rulesyncSubagent = vibeSubagent.toRulesyncSubagent(); - expect(rulesyncSubagent.getRelativeFilePath()).toBe("redteam.md"); + expect(rulesyncSubagent.getRelativeFilePath()).toBe("security-reviewer.md"); expect(rulesyncSubagent.getBody()).toBe("Review for security issues."); expect(rulesyncSubagent.getFrontmatter()).toMatchObject({ targets: ["vibe"],