diff --git a/package.json b/package.json index 4d83d87..3880916 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,10 @@ "description": "Test execution, baseline management, and threshold evaluation for quality gates.", "type": "module", "module": "index.ts", + "sideEffects": false, + "engines": { + "bun": ">=1.0.0" + }, "bin": { "opencode-auto-qcgates": "./src/cli.ts" }, @@ -25,7 +29,8 @@ ], "scripts": { "check": "tsc --noEmit", - "test": "bun test" + "test": "bun test", + "prepublishOnly": "bun run check && bun test" }, "keywords": ["opencode", "testing", "baselining", "quality-gates"], "license": "MIT", diff --git a/plugin.ts b/plugin.ts index 4490857..00a3615 100644 --- a/plugin.ts +++ b/plugin.ts @@ -1,50 +1,118 @@ -import type { Plugin } from "@opencode-ai/plugin"; -import { install, getGlobalConfigPath, getLocalConfigPath, getPackageVersion, type Scope } from "./src/installer.ts"; +import type { Plugin, Config } from "@opencode-ai/plugin"; +import { install, getGlobalConfigPath, getPackageVersion, ScopeResolver, isLocalInstalled, readLocalConfig, mergeConfigWithOverrides } from "./src/installer.ts"; import { join } from "node:path"; const SKILL_NAMES = ["test-baselining", "regression-checking"] as const; +function setExploreSkillPermissions(input: Config): void { + input.agent ??= {}; + input.agent.explore ??= {}; + input.agent.explore.permission ??= {}; + const perm = input.agent.explore.permission as Record; + perm.skill ??= {}; + const skillPerm = perm.skill as Record; + skillPerm["test-baselining"] = "allow"; + skillPerm["regression-checking"] = "allow"; +} + const plugin: Plugin = async ({ directory }) => ({ - config: async () => { + config: async (input: Config) => { const version = await getPackageVersion(); const globalConfigPath = getGlobalConfigPath(); const globalVersionMarker = join(globalConfigPath, "skills", "test-baselining", ".version"); - const isGlobalInstall = directory === globalConfigPath || - directory.startsWith(globalConfigPath + "/") || - directory.startsWith(globalConfigPath + "\\"); - - let scope: Scope; + const scope = ScopeResolver.resolve(directory, globalConfigPath); - if (isGlobalInstall) { - scope = "global"; - } else { + if (scope === "global") { + const marker = globalVersionMarker; try { - const globalVersion = (await Bun.file(globalVersionMarker).text()).trim(); - if (globalVersion === version) { + const installedVersion = (await Bun.file(marker).text()).trim(); + if (installedVersion === version) { + setExploreSkillPermissions(input); return; } } catch { - // Global not installed, proceed with local + // Not installed, proceed + } + + const result = await install("global", directory); + console.log(`\nInstalled opencode-auto-qcgates globally:`); + if (result.skillPaths.length > 0) { + console.log(` Skills: ${result.skillPaths.join(", ")}`); } - scope = "local"; + if (result.commandPaths.length > 0) { + console.log(` Commands: ${result.commandPaths.join(", ")}`); + } + setExploreSkillPermissions(input); + return; } - const marker = scope === "global" - ? globalVersionMarker - : join(directory, ".opencode", "skills", "test-baselining", ".version"); + const localMarker = join(directory, ".opencode", "skills", "test-baselining", ".version"); + const localInstalled = await isLocalInstalled(directory); + + if (localInstalled) { + try { + const installedVersion = (await Bun.file(localMarker).text()).trim(); + if (installedVersion === version) { + const localConfig = await readLocalConfig(directory); + if (localConfig) { + mergeConfigWithOverrides(input as Record, localConfig); + } + setExploreSkillPermissions(input); + return; + } + } catch { + // Proceed with install + } + + const result = await install("local", directory); + console.log(`\nInstalled opencode-auto-qcgates locally:`); + if (result.skillPaths.length > 0) { + console.log(` Skills: ${result.skillPaths.join(", ")}`); + } + if (result.commandPaths.length > 0) { + console.log(` Commands: ${result.commandPaths.join(", ")}`); + } + if (result.migrated) { + console.log(` Migrated: opencode.json → .opencode/opencode.json`); + } + + const newLocalConfig = await readLocalConfig(directory); + if (newLocalConfig) { + mergeConfigWithOverrides(input as Record, newLocalConfig); + } + setExploreSkillPermissions(input); + return; + } try { - const installedVersion = (await Bun.file(marker).text()).trim(); - if (installedVersion === version) { + const globalVersion = (await Bun.file(globalVersionMarker).text()).trim(); + if (globalVersion === version) { + const result = await install("local", directory); + console.log(`\nInstalled opencode-auto-qcgates locally (overriding global):`); + if (result.skillPaths.length > 0) { + console.log(` Skills: ${result.skillPaths.join(", ")}`); + } + if (result.commandPaths.length > 0) { + console.log(` Commands: ${result.commandPaths.join(", ")}`); + } + if (result.migrated) { + console.log(` Migrated: opencode.json → .opencode/opencode.json`); + } + + const localConfig = await readLocalConfig(directory); + if (localConfig) { + mergeConfigWithOverrides(input as Record, localConfig); + } + setExploreSkillPermissions(input); return; } } catch { - // Not installed, proceed + // Global not installed, proceed with local install } - const result = await install(scope, directory); - console.log(`\nInstalled opencode-auto-qcgates ${scope === "global" ? "globally" : "locally"}:`); + const result = await install("local", directory); + console.log(`\nInstalled opencode-auto-qcgates locally:`); if (result.skillPaths.length > 0) { console.log(` Skills: ${result.skillPaths.join(", ")}`); } @@ -54,6 +122,12 @@ const plugin: Plugin = async ({ directory }) => ({ if (result.migrated) { console.log(` Migrated: opencode.json → .opencode/opencode.json`); } + + const finalLocalConfig = await readLocalConfig(directory); + if (finalLocalConfig) { + mergeConfigWithOverrides(input as Record, finalLocalConfig); + } + setExploreSkillPermissions(input); }, }); diff --git a/src/installer.ts b/src/installer.ts index f24fb31..abe4f13 100644 --- a/src/installer.ts +++ b/src/installer.ts @@ -4,6 +4,18 @@ import { join } from "node:path"; export type Scope = "local" | "global"; +export class ScopeResolver { + static resolve(directory: string, globalConfigPath: string): Scope { + const isExact = directory === globalConfigPath; + const isUnderForward = directory.startsWith(globalConfigPath + "/"); + const isUnderBack = directory.startsWith(globalConfigPath + "\\"); + if (isExact || isUnderForward || isUnderBack) { + return "global"; + } + return "local"; + } +} + export interface InstallOptions { addPluginConfig?: boolean; } @@ -373,4 +385,32 @@ export async function status(projectDir: string = process.cwd()): Promise { + const localConfigPath = getLocalConfigPath(projectDir); + const localMarker = join(localConfigPath, "skills", "test-baselining", ".version"); + try { + await Bun.file(localMarker).text(); + return true; + } catch { + return false; + } +} + +export async function readLocalConfig(projectDir: string): Promise | null> { + const localConfigPath = join(getLocalConfigPath(projectDir), "opencode.json"); + return readJsonConfig(localConfigPath); +} + +export function mergeConfigWithOverrides( + input: Record, + localConfig: Record +): void { + for (const [key, value] of Object.entries(localConfig)) { + if (key === "plugin" || key === "agent") { + continue; + } + input[key] = value; + } } \ No newline at end of file diff --git a/tests/plugin.test.ts b/tests/plugin.test.ts index 1c8a600..7b2ca52 100644 --- a/tests/plugin.test.ts +++ b/tests/plugin.test.ts @@ -6,6 +6,7 @@ import plugin from "../plugin.ts"; const TEST_DIR = join(import.meta.dirname, ".test-temp"); beforeAll(async () => { + await rm(TEST_DIR, { recursive: true }).catch(() => {}); await mkdir(TEST_DIR, { recursive: true }); }); @@ -24,8 +25,8 @@ describe("TestBaseliningPlugin", () => { test("installs skills and commands to target directory", async () => { // @ts-ignore - PluginInput requires full context, we only need directory const result = await plugin({ directory: TEST_DIR }); - // @ts-ignore - config returns async function - await (result.config as (() => Promise) | undefined)?.(); + // @ts-ignore - config returns async function that takes Config argument + await (result.config as ((input: unknown) => Promise) | undefined)?.({}); const skillPath = join(TEST_DIR, ".opencode", "skills", "test-baselining", "SKILL.md"); const commandPath = join(TEST_DIR, ".opencode", "commands", "test-baseline.md");