diff --git a/index.cjs b/index.cjs index 84a326d..aeb7491 100644 --- a/index.cjs +++ b/index.cjs @@ -1,18 +1,12 @@ "use strict"; -async function loadPlugin() { - const mod = await import("./src/index.js"); - return mod.default || mod.WorktreeWorkflowPlugin; -} +const plugin = { + id: "@sven1103/opencode-worktree-workflow", + async server(...args) { + const mod = await import("./src/index.js"); + return mod.WorktreeWorkflowPlugin(...args); + }, +}; -async function WorktreeWorkflowPlugin(...args) { - const plugin = await loadPlugin(); - if (typeof plugin !== "function") { - throw new TypeError("Plugin export is not a function"); - } - return plugin(...args); -} - -module.exports = WorktreeWorkflowPlugin; -module.exports.default = WorktreeWorkflowPlugin; -module.exports.WorktreeWorkflowPlugin = WorktreeWorkflowPlugin; +module.exports = plugin; +module.exports.default = plugin; diff --git a/package.json b/package.json index 565a980..3b90741 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,16 @@ "require": "./index.cjs", "import": "./src/index.js", "default": "./index.cjs" + }, + "./server": { + "require": "./index.cjs", + "import": "./src/index.js", + "default": "./index.cjs" } }, + "oc-plugin": [ + "server" + ], "files": [ "index.cjs", "src", diff --git a/src/index.js b/src/index.js index dee9b7a..5c70ede 100644 --- a/src/index.js +++ b/src/index.js @@ -163,6 +163,8 @@ export const __internal = { hasOpaqueRepoRootAbsoluteReference, }; +export const pluginID = "@sven1103/opencode-worktree-workflow"; + export const WorktreeWorkflowPlugin = async ({ $, directory }) => { const service = createWorktreeWorkflowService({ directory, @@ -170,14 +172,14 @@ export const WorktreeWorkflowPlugin = async ({ $, directory }) => { stateStore: createRuntimeStateStore(), }); - async function onToolExecuteBefore(input) { - const toolName = input?.tool?.name ?? input?.toolName; - const args = input?.args || {}; + async function onToolExecuteBefore(input, output) { + const toolName = input?.tool; + const args = output?.args || {}; const classification = classifyToolExecution({ toolName, args }); - if (classification.bypass) return input; + if (classification.bypass) return; const rewritePolicy = getToolRewritePolicy({ toolName }); - const sessionID = input?.sessionID ?? input?.context?.sessionID; + const sessionID = input?.sessionID; let binding = null; if (classification.requiresIsolation) { @@ -199,7 +201,7 @@ export const WorktreeWorkflowPlugin = async ({ $, directory }) => { if (activeTask?.worktree_path) binding = { repoRoot, task: activeTask }; } - if (!binding) return input; + if (!binding) return; if (toolName === "task") { const handoffPath = resolveSafeHandoffPath({ @@ -212,13 +214,11 @@ export const WorktreeWorkflowPlugin = async ({ $, directory }) => { } const workspaceContext = buildWorkspaceContext({ task: binding.task, workspaceRole: deriveWorkspaceRole({ subagentType: args.subagent_type }) }); await enrichHandoffArtifact(handoffPath, workspaceContext); - return { - ...input, - args: { - ...args, - prompt: `${args.prompt}\n\nWorkspace binding:\n- task_id: ${workspaceContext.task_id}\n- worktree_path: ${workspaceContext.worktree_path}\n- workspace_role: ${workspaceContext.workspace_role}`, - }, + output.args = { + ...args, + prompt: `${args.prompt}\n\nWorkspace binding:\n- task_id: ${workspaceContext.task_id}\n- worktree_path: ${workspaceContext.worktree_path}\n- workspace_role: ${workspaceContext.workspace_role}`, }; + return; } const nextArgs = { ...args }; @@ -239,15 +239,15 @@ export const WorktreeWorkflowPlugin = async ({ $, directory }) => { } } - return { ...input, args: nextArgs }; + output.args = nextArgs; } - async function onToolExecuteAfter(input) { - const toolName = input?.tool?.name ?? input?.toolName; + async function onToolExecuteAfter(input, output) { + const toolName = input?.tool; const args = input?.args || {}; - const sessionID = input?.sessionID ?? input?.context?.sessionID; + const sessionID = input?.sessionID; if (toolName === "worktree_prepare" && sessionID) { - const result = input?.metadata?.result ?? input?.result; + const result = output?.metadata?.result; if (result?.branch && result?.worktree_path) { const repoRoot = await service.getRepoRoot(); await service.updateStateForPrepare(repoRoot, sessionID, result, "manual"); @@ -273,21 +273,14 @@ export const WorktreeWorkflowPlugin = async ({ $, directory }) => { }); if (persisted && lifecycle.signal === "complete") { try { - const advisory = await service.buildCleanupAdvisoryPreview({ repoRoot, activeWorktree: input?.context?.worktree ?? input?.worktree ?? directory }); - const parts = Array.isArray(input?.output?.parts) ? [...input.output.parts] : []; - parts.push({ type: "text", text: advisory.message }); + const advisory = await service.buildCleanupAdvisoryPreview({ repoRoot, activeWorktree: directory }); await service.recordToolUsage({ sessionID }); - return { - ...input, - output: { - ...(input?.output && typeof input.output === "object" ? input.output : {}), - parts, - }, - metadata: { - ...(input?.metadata && typeof input.metadata === "object" ? input.metadata : {}), - advisory_cleanup_preview: advisory, - }, + output.output = output.output ? `${output.output}\n\n${advisory.message}` : advisory.message; + output.metadata = { + ...(output?.metadata && typeof output.metadata === "object" ? output.metadata : {}), + advisory_cleanup_preview: advisory, }; + return; } catch { // Advisory preview is non-fatal. } @@ -299,53 +292,39 @@ export const WorktreeWorkflowPlugin = async ({ $, directory }) => { } } if (sessionID) await service.recordToolUsage({ sessionID }); - return input; } - async function onCommandExecuteBefore(input) { - const commandName = input?.command?.name ?? input?.name; - const normalizedName = typeof commandName === "string" ? commandName.replace(/^\//, "") : ""; - if (normalizedName !== "wt-new" && normalizedName !== "wt-clean") return input; + async function onCommandExecuteBefore(input, output) { + const normalizedName = typeof input?.command === "string" ? input.command.replace(/^\//, "") : ""; + if (normalizedName !== "wt-new" && normalizedName !== "wt-clean") return; - const argsText = typeof input?.arguments === "string" ? input.arguments : typeof input?.args === "string" ? input.args : ""; + const argsText = typeof input?.arguments === "string" ? input.arguments : ""; const parts = normalizedName === "wt-new" ? buildWtNewCommandPromptParts(argsText) : buildWtCleanCommandPromptParts(argsText); - - return { - ...input, - output: { - ...(input?.output && typeof input.output === "object" ? input.output : {}), - parts, - }, - }; + output.parts = parts; } - async function onExperimentalChatSystemTransform(input) { - const sessionID = input?.sessionID ?? input?.context?.sessionID; - const existingSystem = typeof input?.system === "string" ? input.system : ""; - if (!sessionID || existingSystem.includes(WORKSPACE_SYSTEM_CONTEXT_MARKER)) return input; + async function onExperimentalChatSystemTransform(input, output) { + const sessionID = input?.sessionID; + const existingSystem = Array.isArray(output?.system) ? output.system : []; + if (!sessionID || existingSystem.some((entry) => entry.includes(WORKSPACE_SYSTEM_CONTEXT_MARKER))) return; const repoRoot = await service.getRepoRoot(); const { activeTask } = await service.getSessionBinding({ repoRoot, sessionID }); - if (!activeTask?.worktree_path) return input; + if (!activeTask?.worktree_path) return; const workspaceContext = buildWorkspaceContext({ task: activeTask, workspaceRole: activeTask.workspace_role, }); const injected = formatWorkspaceSystemContext(workspaceContext); - return { - ...input, - system: existingSystem ? `${existingSystem}\n\n${injected}` : injected, - }; + output.system = [...existingSystem, injected]; } return { - hooks: { - "command.execute.before": onCommandExecuteBefore, - "experimental.chat.system.transform": onExperimentalChatSystemTransform, - "tool.execute.before": onToolExecuteBefore, - "tool.execute.after": onToolExecuteAfter, - }, + "command.execute.before": onCommandExecuteBefore, + "experimental.chat.system.transform": onExperimentalChatSystemTransform, + "tool.execute.before": onToolExecuteBefore, + "tool.execute.after": onToolExecuteAfter, tool: { worktree_prepare: tool({ description: "Create a synced git worktree from a descriptive title", @@ -381,4 +360,9 @@ export const WorktreeWorkflowPlugin = async ({ $, directory }) => { }; }; -export default WorktreeWorkflowPlugin; +const plugin = { + id: pluginID, + server: WorktreeWorkflowPlugin, +}; + +export default plugin; diff --git a/test-support/helpers.js b/test-support/helpers.js index a65e3df..e3ffc0c 100644 --- a/test-support/helpers.js +++ b/test-support/helpers.js @@ -4,7 +4,7 @@ import path from "node:path"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; -import { WorktreeWorkflowPlugin } from "../src/index.js"; +import pluginModule from "../src/index.js"; const execFileAsync = promisify(execFile); @@ -110,7 +110,7 @@ async function createRemoteRepo() { } async function createPlugin(repoPath) { - return WorktreeWorkflowPlugin({ + return pluginModule.server({ $: createShell(repoPath), directory: repoPath, }); @@ -132,30 +132,62 @@ async function executeToolWithMetadata(execute, args, worktree, { sessionID = "t } async function runToolExecuteBeforeHook(plugin, input) { - return plugin.hooks["tool.execute.before"](input); + const output = { args: { ...(input?.args || {}) } }; + await plugin["tool.execute.before"]({ + tool: input?.tool ?? input?.toolName, + sessionID: input?.sessionID, + callID: input?.callID ?? "test-call", + }, output); + return { ...input, args: output.args }; } async function runToolExecuteAfterHook(plugin, input) { - return plugin.hooks["tool.execute.after"](input); + const output = { + title: input?.output?.title ?? "", + output: input?.output?.output ?? "", + metadata: { + ...(input?.metadata ?? {}), + ...(input?.result ? { result: input.result } : {}), + }, + }; + await plugin["tool.execute.after"]({ + tool: input?.tool ?? input?.toolName, + sessionID: input?.sessionID, + callID: input?.callID ?? "test-call", + args: input?.args || {}, + }, output); + return { ...input, output }; } async function runToolExecuteAfterHookWithOutput(plugin, input) { const output = await runToolExecuteAfterHook(plugin, input); + const textOutput = typeof output?.output?.output === "string" ? output.output.output : ""; return { output, - advisoryTextParts: Array.isArray(output?.output?.parts) - ? output.output.parts.filter((part) => part?.type === "text" && typeof part?.text === "string").map((part) => part.text) - : [], - advisoryMetadata: output?.metadata?.advisory_cleanup_preview ?? null, + advisoryTextParts: textOutput ? [textOutput] : [], + advisoryMetadata: output?.output?.metadata?.advisory_cleanup_preview ?? null, }; } async function runCommandExecuteBeforeHook(plugin, input) { - return plugin.hooks["command.execute.before"](input); + const output = { parts: input?.output?.parts || [] }; + await plugin["command.execute.before"]({ + command: typeof input?.command === "string" ? input.command : input?.command?.name, + sessionID: input?.sessionID ?? "test-session", + arguments: input?.arguments ?? "", + }, output); + return { ...input, output }; } async function runExperimentalChatSystemTransformHook(plugin, input) { - return plugin.hooks["experimental.chat.system.transform"](input); + const output = { + system: typeof input?.system === "string" ? [input.system] : Array.isArray(input?.system) ? [...input.system] : [], + }; + await plugin["experimental.chat.system.transform"]({ + sessionID: input?.sessionID, + model: input?.model ?? { id: "test-model" }, + }, output); + return { ...input, system: output.system.join("\n\n") }; } async function createHandoffArtifact(repoPath, sessionID, handoffID, payload = {}) { diff --git a/test/plugin-entrypoint.test.js b/test/plugin-entrypoint.test.js index c49d743..323583f 100644 --- a/test/plugin-entrypoint.test.js +++ b/test/plugin-entrypoint.test.js @@ -10,11 +10,12 @@ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".." test("package root resolves to a callable plugin factory for require loaders", async () => { const pluginModule = require(repoRoot); - assert.equal(typeof pluginModule, "function"); + assert.equal(typeof pluginModule, "object"); + assert.equal(pluginModule.id, "@sven1103/opencode-worktree-workflow"); + assert.equal(typeof pluginModule.server, "function"); assert.equal(pluginModule, pluginModule.default); - assert.equal(pluginModule, pluginModule.WorktreeWorkflowPlugin); - const plugin = await pluginModule({ + const plugin = await pluginModule.server({ $: Object.assign( () => ({ cwd() { @@ -39,5 +40,5 @@ test("package root resolves to a callable plugin factory for require loaders", a }); assert.equal(typeof plugin, "object"); - assert.equal(typeof plugin.hooks["tool.execute.before"], "function"); + assert.equal(typeof plugin["tool.execute.before"], "function"); });