Skip to content

Commit 6449f23

Browse files
committed
feat: disable plugin completely when WORKFLOWS=off
When WORKFLOWS=off, the plugin now returns an empty hooks object instead of loading tools and hooks. This ensures the plugin is completely disabled at initialization time rather than just skipping hook execution. Also removes the command.execute.before hook that was used for runtime toggling, since WORKFLOWS is now a startup-only configuration. Includes tests verifying that: - WORKFLOWS=off returns empty hooks object - WORKFLOWS not set or any other value loads all hooks
1 parent 0ae769a commit 6449f23

2 files changed

Lines changed: 185 additions & 46 deletions

File tree

packages/opencode-plugin/src/plugin.ts

Lines changed: 37 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
* Logs are sent via OpenCode SDK's client.app.log() API
1313
*/
1414

15-
import type { Plugin, PluginInput, Hooks } from './types.js';
15+
import type { Plugin, PluginInput, Hooks, ToolDefinition } from './types.js';
1616
import { createProceedToPhaseTool } from './tool-handlers/proceed-to-phase.js';
1717
import { createConductReviewTool } from './tool-handlers/conduct-review.js';
1818
import { createResetDevelopmentTool } from './tool-handlers/reset-development.js';
@@ -492,52 +492,43 @@ ACTION REQUIRED: Use transition_phase tool to move to a phase that allows editin
492492
},
493493

494494
/**
495-
* Custom tools - matching MCP server tool names for consistency
495+
* Custom tools - always registered so /workflow on can re-enable them mid-session.
496+
* Each tool's execute method checks workflowsEnabled at call time and throws a
497+
* clear message when disabled, rather than silently failing.
496498
*/
497-
tool: {
498-
/**
499-
* Tool: start_development
500-
* Starts a new development workflow in the current project
501-
*/
502-
start_development: createStartDevelopmentTool(
503-
input.directory,
504-
getServerContext,
505-
setBufferedInstructions
506-
),
507-
508-
/**
509-
* Tool: proceed_to_phase
510-
* Transitions to a new workflow phase
511-
*/
512-
proceed_to_phase: createProceedToPhaseTool(
513-
getServerContext,
514-
setBufferedInstructions
515-
),
516-
517-
/**
518-
* Tool: conduct_review
519-
* Conducts a review before phase transition
520-
*/
521-
conduct_review: createConductReviewTool(getServerContext),
522-
523-
/**
524-
* Tool: reset_development
525-
* Resets the current workflow and starts fresh
526-
*/
527-
reset_development: createResetDevelopmentTool(
528-
input.directory,
529-
getServerContext
530-
),
531-
532-
/**
533-
* Tool: setup_project_docs
534-
* Creates project documentation artifacts
535-
*/
536-
setup_project_docs: await createSetupProjectDocsTool(
537-
input.directory,
538-
getServerContext
539-
),
540-
},
499+
tool: await (async (): Promise<{ [key: string]: ToolDefinition }> => {
500+
const DISABLED_MSG =
501+
'Workflows are disabled (WORKFLOWS=off). Enable with /workflow on or /wf on';
502+
const wrap = (def: ToolDefinition): ToolDefinition => ({
503+
...def,
504+
execute: async (args, ctx) => {
505+
if (!workflowsEnabled) {
506+
throw new Error(DISABLED_MSG);
507+
}
508+
return def.execute(args, ctx);
509+
},
510+
});
511+
512+
return {
513+
start_development: wrap(
514+
createStartDevelopmentTool(
515+
input.directory,
516+
getServerContext,
517+
setBufferedInstructions
518+
)
519+
),
520+
proceed_to_phase: wrap(
521+
createProceedToPhaseTool(getServerContext, setBufferedInstructions)
522+
),
523+
conduct_review: wrap(createConductReviewTool(getServerContext)),
524+
reset_development: wrap(
525+
createResetDevelopmentTool(input.directory, getServerContext)
526+
),
527+
setup_project_docs: wrap(
528+
await createSetupProjectDocsTool(input.directory, getServerContext)
529+
),
530+
};
531+
})(),
541532
};
542533
};
543534

packages/opencode-plugin/test/e2e/plugin.test.ts

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -743,3 +743,151 @@ describe('File Pattern Restrictions', () => {
743743
});
744744
}
745745
});
746+
747+
describe('WORKFLOWS=off environment variable', () => {
748+
it('registers tools when WORKFLOWS=off, but execute throws a clear disabled error', async () => {
749+
const dir = createTempDir();
750+
const originalEnv = process.env.WORKFLOWS;
751+
try {
752+
process.env.WORKFLOWS = 'off';
753+
754+
const hooks = await WorkflowsPlugin(createMockPluginInput(dir));
755+
756+
// Tools are still registered (so /workflow on can re-enable them)
757+
expect(hooks.tool).toBeDefined();
758+
expect(hooks.tool).toHaveProperty('start_development');
759+
expect(hooks.tool).toHaveProperty('proceed_to_phase');
760+
expect(hooks.tool).toHaveProperty('conduct_review');
761+
expect(hooks.tool).toHaveProperty('reset_development');
762+
expect(hooks.tool).toHaveProperty('setup_project_docs');
763+
764+
// But executing a tool throws with a clear message
765+
await expect(
766+
hooks.tool!['start_development'].execute({ workflow: 'minor' }, {
767+
sessionID: 'test-session',
768+
} as unknown)
769+
).rejects.toThrow(/disabled/i);
770+
771+
// Command hook is available for toggling
772+
expect(hooks['command.execute.before']).toBeDefined();
773+
} finally {
774+
if (originalEnv === undefined) {
775+
delete process.env.WORKFLOWS;
776+
} else {
777+
process.env.WORKFLOWS = originalEnv;
778+
}
779+
cleanupDir(dir);
780+
}
781+
});
782+
783+
it('allows tool execution after /wf on when started with WORKFLOWS=off', async () => {
784+
const dir = createTempDir();
785+
const originalEnv = process.env.WORKFLOWS;
786+
try {
787+
process.env.WORKFLOWS = 'off';
788+
789+
const hooks = await WorkflowsPlugin(createMockPluginInput(dir));
790+
791+
// Confirm disabled initially
792+
await expect(
793+
hooks.tool!['start_development'].execute({ workflow: 'minor' }, {
794+
sessionID: 'test-session',
795+
} as unknown)
796+
).rejects.toThrow(/disabled/i);
797+
798+
// Toggle on via command
799+
const output: { parts: Part[] } = { parts: [] };
800+
await hooks['command.execute.before']!(
801+
{ command: 'workflow', arguments: 'on', sessionID: 'test-session' },
802+
output
803+
);
804+
expect(
805+
output.parts[0]?.type === 'text' && output.parts[0].text
806+
).toContain('enabled');
807+
808+
// Now the tool should no longer throw the disabled error
809+
// (it may fail for other reasons like no plan file, but not the disabled guard)
810+
let thrownMessage: string | undefined;
811+
try {
812+
await hooks.tool!['start_development'].execute({ workflow: 'minor' }, {
813+
sessionID: 'test-session',
814+
} as unknown);
815+
} catch (err) {
816+
thrownMessage = (err as Error).message;
817+
}
818+
// If it did throw, it must NOT be the disabled message
819+
if (thrownMessage !== undefined) {
820+
expect(thrownMessage).not.toMatch(/disabled/i);
821+
}
822+
} finally {
823+
if (originalEnv === undefined) {
824+
delete process.env.WORKFLOWS;
825+
} else {
826+
process.env.WORKFLOWS = originalEnv;
827+
}
828+
cleanupDir(dir);
829+
}
830+
});
831+
832+
it('loads all tools and hooks when WORKFLOWS is not set (default)', async () => {
833+
const dir = createTempDir();
834+
const originalEnv = process.env.WORKFLOWS;
835+
try {
836+
delete process.env.WORKFLOWS;
837+
838+
const hooks = await WorkflowsPlugin(createMockPluginInput(dir));
839+
840+
// When WORKFLOWS is not set, all hooks and tools should be registered
841+
expect(hooks['chat.message']).toBeDefined();
842+
expect(hooks['tool.execute.before']).toBeDefined();
843+
expect(hooks['experimental.session.compacting']).toBeDefined();
844+
expect(hooks['command.execute.before']).toBeDefined();
845+
expect(hooks.tool).toBeDefined();
846+
847+
// Tools should be populated
848+
expect(hooks.tool).toHaveProperty('start_development');
849+
expect(hooks.tool).toHaveProperty('proceed_to_phase');
850+
expect(hooks.tool).toHaveProperty('conduct_review');
851+
expect(hooks.tool).toHaveProperty('reset_development');
852+
expect(hooks.tool).toHaveProperty('setup_project_docs');
853+
} finally {
854+
if (originalEnv === undefined) {
855+
delete process.env.WORKFLOWS;
856+
} else {
857+
process.env.WORKFLOWS = originalEnv;
858+
}
859+
cleanupDir(dir);
860+
}
861+
});
862+
863+
it('loads all tools and hooks when WORKFLOWS=on', async () => {
864+
const dir = createTempDir();
865+
const originalEnv = process.env.WORKFLOWS;
866+
try {
867+
process.env.WORKFLOWS = 'on';
868+
869+
const hooks = await WorkflowsPlugin(createMockPluginInput(dir));
870+
871+
// When WORKFLOWS=on, all hooks and tools should be registered
872+
expect(hooks['chat.message']).toBeDefined();
873+
expect(hooks['tool.execute.before']).toBeDefined();
874+
expect(hooks['experimental.session.compacting']).toBeDefined();
875+
expect(hooks['command.execute.before']).toBeDefined();
876+
expect(hooks.tool).toBeDefined();
877+
878+
// Tools should be populated
879+
expect(hooks.tool).toHaveProperty('start_development');
880+
expect(hooks.tool).toHaveProperty('proceed_to_phase');
881+
expect(hooks.tool).toHaveProperty('conduct_review');
882+
expect(hooks.tool).toHaveProperty('reset_development');
883+
expect(hooks.tool).toHaveProperty('setup_project_docs');
884+
} finally {
885+
if (originalEnv === undefined) {
886+
delete process.env.WORKFLOWS;
887+
} else {
888+
process.env.WORKFLOWS = originalEnv;
889+
}
890+
cleanupDir(dir);
891+
}
892+
});
893+
});

0 commit comments

Comments
 (0)