Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand All @@ -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",
Expand Down
120 changes: 97 additions & 23 deletions plugin.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
perm.skill ??= {};
const skillPerm = perm.skill as Record<string, string>;
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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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(", ")}`);
}
Expand All @@ -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<string, unknown>, finalLocalConfig);
}
setExploreSkillPermissions(input);
},
});

Expand Down
40 changes: 40 additions & 0 deletions src/installer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -373,4 +385,32 @@ export async function status(projectDir: string = process.cwd()): Promise<Status
local: localStatus,
global: globalStatus,
};
}

export async function isLocalInstalled(projectDir: string): Promise<boolean> {
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<Record<string, unknown> | null> {
const localConfigPath = join(getLocalConfigPath(projectDir), "opencode.json");
return readJsonConfig(localConfigPath);
}

export function mergeConfigWithOverrides(
input: Record<string, unknown>,
localConfig: Record<string, unknown>
): void {
for (const [key, value] of Object.entries(localConfig)) {
if (key === "plugin" || key === "agent") {
continue;
}
input[key] = value;
}
}
5 changes: 3 additions & 2 deletions tests/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
});

Expand All @@ -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<void>) | undefined)?.();
// @ts-ignore - config returns async function that takes Config argument
await (result.config as ((input: unknown) => Promise<void>) | undefined)?.({});

const skillPath = join(TEST_DIR, ".opencode", "skills", "test-baselining", "SKILL.md");
const commandPath = join(TEST_DIR, ".opencode", "commands", "test-baseline.md");
Expand Down