diff --git a/README.md b/README.md index 07a71f36..405ff661 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ See [Quick Start guide](https://dyoshikawa.github.io/rulesync/getting-started/qu | Cursor | cursor | ✅ | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ | | | deepagents-cli | deepagents | ✅ | | ✅ 🌏 | | ✅ | ✅ | 🌏 | | | Factory Droid | factorydroid | ✅ 🌏 | | ✅ 🌏 | 🎮 | 🎮 | 🎮 | ✅ 🌏 | | -| OpenCode | opencode | ✅ 🌏 | | ✅ 🌏 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | +| OpenCode | opencode | ✅ 🌏 | | ✅ 🌏 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | Cline | cline | ✅ | ✅ | ✅ | ✅ 🌏 | | ✅ 🌏 | | | | Kilo Code | kilo | ✅ 🌏 | ✅ | ✅ | ✅ 🌏 | | ✅ 🌏 | | | | Roo Code | roo | ✅ | ✅ | ✅ | ✅ | 🎮 | ✅ 🌏 | | | diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index db5017aa..6e41aac9 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -382,4 +382,6 @@ Example: For Claude Code, this generates `permissions.allow`, `permissions.ask`, and `permissions.deny` arrays in `.claude/settings.json` using PascalCase tool names (e.g., `Bash(git *)`, `Edit(src/**)`, `Read(.env)`). +For OpenCode, this generates the `permission` object in `opencode.json` / `opencode.jsonc` (project mode) or `.config/opencode/opencode.json` / `.config/opencode/opencode.jsonc` (global mode), preserving other existing OpenCode config fields. + > **Note: Interaction with ignore feature.** Both the ignore feature and the permissions feature can manage `Read` tool deny entries in `.claude/settings.json`. When both features configure the `Read` tool, the **permissions feature takes precedence** and a warning is emitted. If you only need to restrict file reads based on glob patterns, use the ignore feature (`.rulesync/.aiignore`). Use permissions only when you need fine-grained `allow`/`ask`/`deny` control over the `Read` tool. diff --git a/docs/reference/supported-tools.md b/docs/reference/supported-tools.md index 3b6a5d1d..a4de481c 100644 --- a/docs/reference/supported-tools.md +++ b/docs/reference/supported-tools.md @@ -14,7 +14,7 @@ Rulesync supports both **generation** and **import** for All of the major AI cod | Goose | goose | ✅ 🌏 | ✅ | | | | | | | | Cursor | cursor | ✅ | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ | | | Factory Droid | factorydroid | ✅ 🌏 | | ✅ 🌏 | 🎮 | 🎮 | 🎮 | ✅ 🌏 | | -| OpenCode | opencode | ✅ 🌏 | | ✅ 🌏 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | +| OpenCode | opencode | ✅ 🌏 | | ✅ 🌏 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | Cline | cline | ✅ | ✅ | ✅ | ✅ 🌏 | | ✅ 🌏 | | | | Kilo Code | kilo | ✅ 🌏 | ✅ | ✅ | ✅ 🌏 | | ✅ 🌏 | | | | Roo Code | roo | ✅ | ✅ | ✅ | ✅ | 🎮 | ✅ 🌏 | | | diff --git a/skills/rulesync/file-formats.md b/skills/rulesync/file-formats.md index db5017aa..6e41aac9 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -382,4 +382,6 @@ Example: For Claude Code, this generates `permissions.allow`, `permissions.ask`, and `permissions.deny` arrays in `.claude/settings.json` using PascalCase tool names (e.g., `Bash(git *)`, `Edit(src/**)`, `Read(.env)`). +For OpenCode, this generates the `permission` object in `opencode.json` / `opencode.jsonc` (project mode) or `.config/opencode/opencode.json` / `.config/opencode/opencode.jsonc` (global mode), preserving other existing OpenCode config fields. + > **Note: Interaction with ignore feature.** Both the ignore feature and the permissions feature can manage `Read` tool deny entries in `.claude/settings.json`. When both features configure the `Read` tool, the **permissions feature takes precedence** and a warning is emitted. If you only need to restrict file reads based on glob patterns, use the ignore feature (`.rulesync/.aiignore`). Use permissions only when you need fine-grained `allow`/`ask`/`deny` control over the `Read` tool. diff --git a/skills/rulesync/supported-tools.md b/skills/rulesync/supported-tools.md index 3b6a5d1d..a4de481c 100644 --- a/skills/rulesync/supported-tools.md +++ b/skills/rulesync/supported-tools.md @@ -14,7 +14,7 @@ Rulesync supports both **generation** and **import** for All of the major AI cod | Goose | goose | ✅ 🌏 | ✅ | | | | | | | | Cursor | cursor | ✅ | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ | | | Factory Droid | factorydroid | ✅ 🌏 | | ✅ 🌏 | 🎮 | 🎮 | 🎮 | ✅ 🌏 | | -| OpenCode | opencode | ✅ 🌏 | | ✅ 🌏 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | +| OpenCode | opencode | ✅ 🌏 | | ✅ 🌏 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | Cline | cline | ✅ | ✅ | ✅ | ✅ 🌏 | | ✅ 🌏 | | | | Kilo Code | kilo | ✅ 🌏 | ✅ | ✅ | ✅ 🌏 | | ✅ 🌏 | | | | Roo Code | roo | ✅ | ✅ | ✅ | ✅ | 🎮 | ✅ 🌏 | | | diff --git a/src/e2e/e2e-permissions.spec.ts b/src/e2e/e2e-permissions.spec.ts new file mode 100644 index 00000000..9f0207fd --- /dev/null +++ b/src/e2e/e2e-permissions.spec.ts @@ -0,0 +1,105 @@ +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH } from "../constants/rulesync-paths.js"; +import { readFileContent, writeFileContent } from "../utils/file.js"; +import { + runGenerate, + runImport, + useGlobalTestDirectories, + useTestDirectory, +} from "./e2e-helper.js"; + +describe("E2E: permissions", () => { + const { getTestDir } = useTestDirectory(); + + it("should generate opencode permissions from .rulesync/permissions.json", async () => { + const testDir = getTestDir(); + + await writeFileContent( + join(testDir, RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH), + JSON.stringify( + { + permission: { + bash: { "*": "ask", "git *": "allow" }, + read: { ".env": "deny" }, + }, + }, + null, + 2, + ), + ); + + await runGenerate({ target: "opencode", features: "permissions" }); + + const content = JSON.parse(await readFileContent(join(testDir, "opencode.jsonc"))); + expect(content.permission.bash["git *"]).toBe("allow"); + expect(content.permission.read[".env"]).toBe("deny"); + }); +}); + +describe("E2E: permissions (import)", () => { + const { getTestDir } = useTestDirectory(); + + it("should import opencode permissions into .rulesync/permissions.json", async () => { + const testDir = getTestDir(); + + await writeFileContent( + join(testDir, "opencode.json"), + JSON.stringify( + { + permission: { + bash: { "*": "ask", "npm *": "allow" }, + read: { ".env": "deny" }, + }, + }, + null, + 2, + ), + ); + + await runImport({ target: "opencode", features: "permissions" }); + + const content = JSON.parse( + await readFileContent(join(testDir, RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH)), + ); + expect(content.permission.bash["npm *"]).toBe("allow"); + expect(content.permission.read[".env"]).toBe("deny"); + }); +}); + +describe("E2E: permissions (global mode)", () => { + const { getProjectDir, getHomeDir } = useGlobalTestDirectories(); + + it("should generate opencode 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" }, + }, + }, + null, + 2, + ), + ); + + await runGenerate({ + target: "opencode", + features: "permissions", + global: true, + env: { HOME_DIR: homeDir }, + }); + + const generated = JSON.parse( + await readFileContent(join(homeDir, ".config", "opencode", "opencode.jsonc")), + ); + expect(generated.permission.bash["git status *"]).toBe("allow"); + }); +}); diff --git a/src/features/permissions/opencode-permissions.test.ts b/src/features/permissions/opencode-permissions.test.ts new file mode 100644 index 00000000..dc39e3d0 --- /dev/null +++ b/src/features/permissions/opencode-permissions.test.ts @@ -0,0 +1,85 @@ +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + RULESYNC_PERMISSIONS_FILE_NAME, + RULESYNC_RELATIVE_DIR_PATH, +} from "../../constants/rulesync-paths.js"; +import { setupTestDirectory } from "../../test-utils/test-directories.js"; +import { ensureDir, writeFileContent } from "../../utils/file.js"; +import { OpencodePermissions } from "./opencode-permissions.js"; +import { RulesyncPermissions } from "./rulesync-permissions.js"; + +describe("OpencodePermissions", () => { + 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 resolve project and global settable paths", () => { + expect(OpencodePermissions.getSettablePaths()).toEqual({ + relativeDirPath: ".", + relativeFilePath: "opencode.json", + }); + expect(OpencodePermissions.getSettablePaths({ global: true })).toEqual({ + relativeDirPath: join(".config", "opencode"), + relativeFilePath: "opencode.json", + }); + }); + + it("should load opencode.jsonc and initialize permission", async () => { + await writeFileContent(join(testDir, "opencode.jsonc"), JSON.stringify({ model: "x" })); + + const instance = await OpencodePermissions.fromFile({ baseDir: testDir }); + const json = instance.getJson(); + + expect(instance.getRelativeFilePath()).toBe("opencode.jsonc"); + expect(json.permission).toEqual({}); + }); + + it("should merge rulesync permission into existing opencode config", async () => { + await writeFileContent(join(testDir, "opencode.json"), JSON.stringify({ model: "x" })); + const rulesyncPermissions = new RulesyncPermissions({ + baseDir: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: RULESYNC_PERMISSIONS_FILE_NAME, + fileContent: JSON.stringify({ + permission: { + bash: { "*": "ask", "git *": "allow" }, + }, + }), + }); + + const instance = await OpencodePermissions.fromRulesyncPermissions({ + baseDir: testDir, + rulesyncPermissions, + }); + const json = JSON.parse(instance.getFileContent()); + + expect(instance.getRelativeFilePath()).toBe("opencode.json"); + expect(json.model).toBe("x"); + expect(json.permission.bash["git *"]).toBe("allow"); + }); + + it("should support global mode file resolution", async () => { + await ensureDir(join(testDir, ".config", "opencode")); + await writeFileContent( + join(testDir, ".config", "opencode", "opencode.jsonc"), + JSON.stringify({ permission: { bash: "ask" } }), + ); + + const instance = await OpencodePermissions.fromFile({ baseDir: testDir, global: true }); + const rulesync = instance.toRulesyncPermissions().getJson(); + + expect(rulesync.permission.bash).toEqual({ "*": "ask" }); + }); +}); diff --git a/src/features/permissions/opencode-permissions.ts b/src/features/permissions/opencode-permissions.ts new file mode 100644 index 00000000..5f8dc841 --- /dev/null +++ b/src/features/permissions/opencode-permissions.ts @@ -0,0 +1,175 @@ +import { join } from "node:path"; + +import { parse as parseJsonc } from "jsonc-parser"; +import { z } from "zod/mini"; + +import type { AiFileParams } from "../../types/ai-file.js"; +import { ValidationResult } from "../../types/ai-file.js"; +import type { 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"; + +const OpencodePermissionSchema = z.union([ + z.enum(["allow", "ask", "deny"]), + z.record(z.string(), z.enum(["allow", "ask", "deny"])), +]); + +const OpencodePermissionsConfigSchema = z.looseObject({ + permission: z.optional(z.record(z.string(), OpencodePermissionSchema)), +}); + +type OpencodePermissionsConfig = z.infer; + +export class OpencodePermissions extends ToolPermissions { + private readonly json: OpencodePermissionsConfig; + + constructor(params: AiFileParams) { + super(params); + this.json = OpencodePermissionsConfigSchema.parse(parseJsonc(this.fileContent || "{}")); + } + + getJson(): OpencodePermissionsConfig { + return this.json; + } + + override isDeletable(): boolean { + return false; + } + + static getSettablePaths({ + global = false, + }: { global?: boolean } = {}): ToolPermissionsSettablePaths { + return global + ? { relativeDirPath: join(".config", "opencode"), relativeFilePath: "opencode.json" } + : { relativeDirPath: ".", relativeFilePath: "opencode.json" }; + } + + static async fromFile({ + baseDir = process.cwd(), + validate = true, + global = false, + }: ToolPermissionsFromFileParams): Promise { + const basePaths = OpencodePermissions.getSettablePaths({ global }); + const jsonDir = join(baseDir, basePaths.relativeDirPath); + + const jsoncPath = join(jsonDir, "opencode.jsonc"); + const jsonPath = join(jsonDir, "opencode.json"); + + let fileContent = await readFileContentOrNull(jsoncPath); + let relativeFilePath = "opencode.jsonc"; + + if (!fileContent) { + fileContent = await readFileContentOrNull(jsonPath); + if (fileContent) { + relativeFilePath = "opencode.json"; + } + } + + const parsed = parseJsonc(fileContent ?? "{}"); + const nextJson = { ...parsed, permission: parsed.permission ?? {} }; + + return new OpencodePermissions({ + baseDir, + relativeDirPath: basePaths.relativeDirPath, + relativeFilePath, + fileContent: JSON.stringify(nextJson, null, 2), + validate, + }); + } + + static async fromRulesyncPermissions({ + baseDir = process.cwd(), + rulesyncPermissions, + global = false, + }: ToolPermissionsFromRulesyncPermissionsParams): Promise { + const basePaths = OpencodePermissions.getSettablePaths({ global }); + const jsonDir = join(baseDir, basePaths.relativeDirPath); + + const jsoncPath = join(jsonDir, "opencode.jsonc"); + const jsonPath = join(jsonDir, "opencode.json"); + + let fileContent = await readFileContentOrNull(jsoncPath); + let relativeFilePath = "opencode.jsonc"; + + if (!fileContent) { + fileContent = await readFileContentOrNull(jsonPath); + if (fileContent) { + relativeFilePath = "opencode.json"; + } + } + + const parsed = parseJsonc(fileContent ?? "{}"); + const nextJson = { + ...parsed, + permission: rulesyncPermissions.getJson().permission, + }; + + return new OpencodePermissions({ + baseDir, + relativeDirPath: basePaths.relativeDirPath, + relativeFilePath, + fileContent: JSON.stringify(nextJson, null, 2), + validate: true, + }); + } + + toRulesyncPermissions(): RulesyncPermissions { + const permission = this.normalizePermission(this.json.permission); + return this.toRulesyncPermissionsDefault({ + fileContent: JSON.stringify({ permission }, null, 2), + }); + } + + validate(): ValidationResult { + try { + const json = JSON.parse(this.fileContent || "{}"); + const result = OpencodePermissionsConfigSchema.safeParse(json); + if (!result.success) { + return { success: false, error: result.error }; + } + return { success: true, error: null }; + } catch (error) { + return { + success: false, + error: new Error(`Failed to parse OpenCode permissions JSON: ${formatError(error)}`), + }; + } + } + + static forDeletion({ + baseDir = process.cwd(), + relativeDirPath, + relativeFilePath, + }: ToolPermissionsForDeletionParams): OpencodePermissions { + return new OpencodePermissions({ + baseDir, + relativeDirPath, + relativeFilePath, + fileContent: JSON.stringify({ permission: {} }, null, 2), + validate: false, + }); + } + + private normalizePermission( + permission: OpencodePermissionsConfig["permission"] | undefined, + ): PermissionsConfig["permission"] { + if (!permission) { + return {}; + } + + return Object.fromEntries( + Object.entries(permission).map(([tool, value]) => [ + tool, + typeof value === "string" ? { "*": value } : value, + ]), + ); + } +} diff --git a/src/features/permissions/permissions-processor.test.ts b/src/features/permissions/permissions-processor.test.ts index a644257c..9cacbd9a 100644 --- a/src/features/permissions/permissions-processor.test.ts +++ b/src/features/permissions/permissions-processor.test.ts @@ -10,6 +10,7 @@ import { createMockLogger } from "../../test-utils/mock-logger.js"; import { setupTestDirectory } from "../../test-utils/test-directories.js"; import { ensureDir, readFileContent, writeFileContent } from "../../utils/file.js"; import { ClaudecodePermissions } from "./claudecode-permissions.js"; +import { OpencodePermissions } from "./opencode-permissions.js"; import { PermissionsProcessor } from "./permissions-processor.js"; import { RulesyncPermissions } from "./rulesync-permissions.js"; @@ -68,19 +69,19 @@ describe("PermissionsProcessor", () => { }); describe("getToolTargets", () => { - it("should return claudecode for project mode", () => { + it("should return claudecode and opencode for project mode", () => { const targets = PermissionsProcessor.getToolTargets(); - expect(targets).toEqual(["claudecode"]); + expect(targets).toEqual(["claudecode", "opencode"]); }); - it("should return empty array for global mode", () => { + it("should return opencode for global mode", () => { const targets = PermissionsProcessor.getToolTargets({ global: true }); - expect(targets).toEqual([]); + expect(targets).toEqual(["opencode"]); }); it("should return importable targets", () => { const targets = PermissionsProcessor.getToolTargets({ importOnly: true }); - expect(targets).toEqual(["claudecode"]); + expect(targets).toEqual(["claudecode", "opencode"]); }); }); @@ -159,6 +160,28 @@ describe("PermissionsProcessor", () => { // ClaudecodePermissions.isDeletable() returns false, so should be empty expect(files).toHaveLength(0); }); + + it("should load OpenCode opencode.jsonc", async () => { + await writeFileContent( + join(testDir, "opencode.jsonc"), + JSON.stringify({ + permission: { + bash: { "git *": "allow" }, + }, + }), + ); + + const processor = new PermissionsProcessor({ + logger, + baseDir: testDir, + toolTarget: "opencode", + }); + + const files = await processor.loadToolFiles(); + + expect(files).toHaveLength(1); + expect(files[0]).toBeInstanceOf(OpencodePermissions); + }); }); describe("convertRulesyncFilesToToolFiles", () => { diff --git a/src/features/permissions/permissions-processor.ts b/src/features/permissions/permissions-processor.ts index b0ad92fb..8ecf3b22 100644 --- a/src/features/permissions/permissions-processor.ts +++ b/src/features/permissions/permissions-processor.ts @@ -8,6 +8,7 @@ import type { ToolTarget } from "../../types/tool-targets.js"; import { formatError } from "../../utils/error.js"; import type { Logger } from "../../utils/logger.js"; import { ClaudecodePermissions } from "./claudecode-permissions.js"; +import { OpencodePermissions } from "./opencode-permissions.js"; import { RulesyncPermissions } from "./rulesync-permissions.js"; import type { ToolPermissionsForDeletionParams, @@ -17,7 +18,7 @@ import type { } from "./tool-permissions.js"; import { ToolPermissions } from "./tool-permissions.js"; -const permissionsProcessorToolTargetTuple = ["claudecode"] as const; +const permissionsProcessorToolTargetTuple = ["claudecode", "opencode"] as const; export type PermissionsProcessorToolTarget = (typeof permissionsProcessorToolTargetTuple)[number]; @@ -30,7 +31,7 @@ type ToolPermissionsFactory = { ): ToolPermissions | Promise; fromFile(params: ToolPermissionsFromFileParams): Promise; forDeletion(params: ToolPermissionsForDeletionParams): ToolPermissions; - getSettablePaths(): ToolPermissionsSettablePaths; + getSettablePaths(options?: { global?: boolean }): ToolPermissionsSettablePaths; }; meta: { supportsProject: boolean; @@ -51,29 +52,33 @@ const toolPermissionsFactories = new Map f.meta.supportsProject) - .map(([t]) => t); - -const permissionsProcessorToolTargetsImportable: ToolTarget[] = [ - ...toolPermissionsFactories.entries(), -] - .filter(([, f]) => f.meta.supportsProject && f.meta.supportsImport) - .map(([t]) => t); - export class PermissionsProcessor extends FeatureProcessor { private readonly toolTarget: PermissionsProcessorToolTarget; + private readonly global: boolean; constructor({ baseDir = process.cwd(), toolTarget, + global = false, dryRun = false, logger, }: { baseDir?: string; toolTarget: ToolTarget; + global?: boolean; dryRun?: boolean; logger: Logger; }) { @@ -85,6 +90,7 @@ export class PermissionsProcessor extends FeatureProcessor { ); } this.toolTarget = result.data; + this.global = global; } async loadRulesyncFiles(): Promise { @@ -111,13 +117,14 @@ export class PermissionsProcessor extends FeatureProcessor { try { const factory = toolPermissionsFactories.get(this.toolTarget); if (!factory) throw new Error(`Unsupported tool target: ${this.toolTarget}`); - const paths = factory.class.getSettablePaths(); + const paths = factory.class.getSettablePaths({ global: this.global }); if (forDeletion) { const toolPermissions = factory.class.forDeletion({ baseDir: this.baseDir, relativeDirPath: paths.relativeDirPath, relativeFilePath: paths.relativeFilePath, + global: this.global, }); const list = toolPermissions.isDeletable?.() !== false ? [toolPermissions] : []; return list; @@ -126,6 +133,7 @@ export class PermissionsProcessor extends FeatureProcessor { const toolPermissions = await factory.class.fromFile({ baseDir: this.baseDir, validate: true, + global: this.global, }); return [toolPermissions]; } catch (error) { @@ -154,6 +162,7 @@ export class PermissionsProcessor extends FeatureProcessor { baseDir: this.baseDir, rulesyncPermissions, logger: this.logger, + global: this.global, }); return [toolPermissions]; @@ -168,9 +177,9 @@ export class PermissionsProcessor extends FeatureProcessor { global = false, importOnly = false, }: { global?: boolean; importOnly?: boolean } = {}): ToolTarget[] { - if (global) { - return []; - } - return importOnly ? permissionsProcessorToolTargetsImportable : permissionsProcessorToolTargets; + return [...toolPermissionsFactories.entries()] + .filter(([, f]) => (global ? f.meta.supportsGlobal : f.meta.supportsProject)) + .filter(([, f]) => (importOnly ? f.meta.supportsImport : true)) + .map(([target]) => target); } } diff --git a/src/features/permissions/tool-permissions.ts b/src/features/permissions/tool-permissions.ts index 6b6c36a4..ad0c7903 100644 --- a/src/features/permissions/tool-permissions.ts +++ b/src/features/permissions/tool-permissions.ts @@ -15,6 +15,7 @@ export type ToolPermissionsFromRulesyncPermissionsParams = Omit< > & { rulesyncPermissions: RulesyncPermissions; logger?: Logger; + global?: boolean; }; export type ToolPermissionsSettablePaths = { @@ -22,16 +23,19 @@ export type ToolPermissionsSettablePaths = { relativeFilePath: string; }; -export type ToolPermissionsFromFileParams = Pick; +export type ToolPermissionsFromFileParams = Pick & { + global?: boolean; +}; export type ToolPermissionsForDeletionParams = { baseDir?: string; relativeDirPath: string; relativeFilePath: string; + global?: boolean; }; export abstract class ToolPermissions extends ToolFile { - static getSettablePaths(): ToolPermissionsSettablePaths { + static getSettablePaths(_options?: { global?: boolean }): ToolPermissionsSettablePaths { throw new Error("Please implement this method in the subclass."); } diff --git a/src/lib/generate.test.ts b/src/lib/generate.test.ts index 6720668f..ea535f5d 100644 --- a/src/lib/generate.test.ts +++ b/src/lib/generate.test.ts @@ -578,6 +578,9 @@ describe("generate", () => { it("should skip permissions generation in global mode", async () => { mockConfig.getFeatures.mockReturnValue(["permissions"]); mockConfig.getGlobal.mockReturnValue(true); + vi.mocked(PermissionsProcessor.getToolTargets).mockImplementation((params) => + params?.global ? ["opencode"] : ["claudecode"], + ); const result = await generate({ logger, config: mockConfig as never }); diff --git a/src/lib/generate.ts b/src/lib/generate.ts index 361cf803..7cfe2c1d 100644 --- a/src/lib/generate.ts +++ b/src/lib/generate.ts @@ -599,10 +599,6 @@ async function generatePermissionsCore(params: { logger, }); - if (config.getGlobal()) { - return { count: 0, paths: [], hasDiff: false }; - } - let totalCount = 0; const allPaths: string[] = []; let hasDiff = false; @@ -617,6 +613,7 @@ async function generatePermissionsCore(params: { const processor = new PermissionsProcessor({ baseDir, toolTarget, + global: config.getGlobal(), dryRun: config.isPreviewMode(), logger, }); diff --git a/src/lib/import.test.ts b/src/lib/import.test.ts index b6196a4b..57872b09 100644 --- a/src/lib/import.test.ts +++ b/src/lib/import.test.ts @@ -684,6 +684,9 @@ describe("importFromTool", () => { it("should skip permissions import in global mode", async () => { mockConfig.getFeatures.mockReturnValue(["permissions"]); mockConfig.getGlobal.mockReturnValue(true); + vi.mocked(PermissionsProcessor.getToolTargets).mockImplementation((params) => + params?.global ? ["opencode"] : ["claudecode"], + ); const result = await importFromTool({ logger, diff --git a/src/lib/import.ts b/src/lib/import.ts index 05d55c23..0242a69c 100644 --- a/src/lib/import.ts +++ b/src/lib/import.ts @@ -366,13 +366,11 @@ async function importPermissionsCore(params: { return 0; } - if (config.getGlobal()) { - logger.debug("Skipping permissions file import (not supported in global mode)"); - return 0; - } - - const allTargets = PermissionsProcessor.getToolTargets(); - const importableTargets = PermissionsProcessor.getToolTargets({ importOnly: true }); + const allTargets = PermissionsProcessor.getToolTargets({ global: config.getGlobal() }); + const importableTargets = PermissionsProcessor.getToolTargets({ + global: config.getGlobal(), + importOnly: true, + }); if (!allTargets.includes(tool)) { return 0; @@ -386,6 +384,7 @@ async function importPermissionsCore(params: { const permissionsProcessor = new PermissionsProcessor({ baseDir: config.getBaseDirs()[0] ?? ".", toolTarget: tool, + global: config.getGlobal(), logger, });