Skip to content
Open
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
43 changes: 27 additions & 16 deletions src/v2/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,21 +37,32 @@ export async function handleShowServerUrlCommand(options?: ServerOptions): Promi
return `PTY Sessions Web Interface URL: ${server.server.url.origin}`
}

export function registerV2Commands(draft: CommandDraft, _options?: OpencodePtyOptions): void {
if (typeof draft.update === 'function') {
draft.update(PTY_OPEN_CLIENT_COMMAND, (cmd) => {
if (cmd) {
cmd.description = 'Open PTY Sessions Web Interface'
cmd.template =
'This command will start the PTY Sessions Web Interface in your default browser.'
}
})

draft.update(PTY_SHOW_SERVER_URL_COMMAND, (cmd) => {
if (cmd) {
cmd.description = 'Show PTY Sessions Web Interface URL'
cmd.template = 'This command will show the PTY Sessions Web Interface URL.'
}
})
/**
* Registers the PTY slash commands with opencode v2's `CommandEditor`.
*
* opencode v2's `command.transform` draft exposes `add(definition)` only
* (there is no `update`), so commands must be created with an `execute`
* handler rather than "updated".
*/
export function registerV2Commands(draft: CommandDraft, options?: OpencodePtyOptions): void {
if (typeof draft.add !== 'function') {
return
}
const add = draft.add.bind(draft)

add({
name: PTY_OPEN_CLIENT_COMMAND,
description: 'Open PTY Sessions Web Interface',
execute: async () => {
await handleOpenClientCommand(options)
},
})

add({
name: PTY_SHOW_SERVER_URL_COMMAND,
description: 'Show PTY Sessions Web Interface URL',
execute: async () => {
await handleShowServerUrlCommand(options)
},
})
}
7 changes: 7 additions & 0 deletions src/v2/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createV2Adapter } from '../adapters/v2/index.ts'
import { installHostAdapter } from '../adapters/index.ts'
import { getOrCreateServer, registerV2Commands } from './commands.ts'
import { registerV2Tools } from './tools.ts'
import { define, type PluginContextV2, type PluginV2 } from './types.ts'

export * from './commands.ts'
Expand All @@ -18,6 +19,12 @@ export const Plugin: PluginV2 = define({
const adapter = createV2Adapter()
installHostAdapter(adapter)

if (ctx.tool && typeof ctx.tool.transform === 'function') {
await ctx.tool.transform((draft) => {
registerV2Tools(draft)
})
}

if (ctx.command && typeof ctx.command.transform === 'function') {
await ctx.command.transform((draft) => {
registerV2Commands(draft, options)
Expand Down
38 changes: 38 additions & 0 deletions src/v2/tools.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { tool } from '@opencode-ai/plugin'
import { ptyKill } from '../plugin/pty/tools/kill.ts'
import { ptyList } from '../plugin/pty/tools/list.ts'
import { ptyRead } from '../plugin/pty/tools/read.ts'
import { ptySpawn } from '../plugin/pty/tools/spawn.ts'
import { ptyWrite } from '../plugin/pty/tools/write.ts'
import type { ToolDraft, ToolInfoV2 } from './types.ts'

export const ptyTools = {
pty_spawn: ptySpawn,
Expand All @@ -13,3 +15,39 @@ export const ptyTools = {
} as const

export type PTYToolName = keyof typeof ptyTools

type V1ToolDefinition = {
description: string
args: Record<string, unknown>
execute: (args: never, context: never) => Promise<string>
}

/**
* Registers the PTY tools with opencode v2's `ToolEditor`.
*
* The tool definitions are authored against the V1 `tool()` helper
* (`{ description, args, execute }`). opencode v2 expects `Tool.Info`
* (`{ name, input, description, execute }`); we adapt:
* - `args` (Zod raw shape) -> `input`: JSON Schema (Zod v4 `toJSONSchema`)
* - string result -> `{ content }`
*/
export function registerV2Tools(draft: ToolDraft): void {
if (typeof draft.add !== 'function') {
return
}
const add = draft.add.bind(draft)
const tools = ptyTools as unknown as Record<string, V1ToolDefinition>

for (const [name, definition] of Object.entries(tools)) {
const info: ToolInfoV2 = {
name,
description: definition.description,
input: tool.schema.toJSONSchema(tool.schema.object(definition.args)),
execute: async (input, context) => {
const result = await definition.execute(input as never, context as never)
return typeof result === 'string' ? { content: result } : result
},
}
add(info)
}
}
35 changes: 29 additions & 6 deletions src/v2/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,44 @@ export interface OpencodePtyOptions {
autostart?: boolean
}

export interface CommandInfo {
title?: string
/**
* Command definition accepted by opencode v2's CommandEditor.
*
* NOTE: opencode v2's `command.transform` draft exposes `add(definition)`
* (see `CommandEditor` in @opencode-ai/plugin). There is no `update`/`list`/`get`.
*/
export interface CommandDefinition {
name: string
description?: string
template?: string
[key: string]: unknown
execute: (input: unknown) => Promise<void> | void
}

export interface CommandDraft {
add?(command: CommandDefinition): void
list?(): readonly unknown[]
get?(name: string): unknown
update?(name: string, update: (command: CommandInfo) => void): void
remove?(name: string): void
[key: string]: unknown
}

/**
* Tool definition accepted by opencode v2's ToolEditor.
*
* Mirrors `Tool.Info` from @opencode-ai/plugin's promise API:
* { name, input, description, execute(input, context) }
*/
export interface ToolInfoV2 {
name: string
description: string
input: unknown
execute: (input: unknown, context: unknown) => Promise<unknown>
}

export interface ToolDraft {
add?(tool: ToolInfoV2): void
[key: string]: unknown
}

export interface PluginContextV2 {
readonly options?: OpencodePtyOptions & Record<string, unknown>
readonly command?: {
Expand All @@ -42,7 +65,7 @@ export interface PluginContextV2 {
reload?(): Promise<void> | void
}
readonly tool?: {
transform(callback: (tools: unknown) => Promise<void> | void): Promise<unknown> | undefined
transform(callback: (tools: ToolDraft) => Promise<void> | void): Promise<unknown> | undefined
reload?(): Promise<void> | void
}
readonly [key: string]: unknown
Expand Down
51 changes: 41 additions & 10 deletions test/opencode-v2-live.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@ import {
ptyTools,
stopActiveServer,
} from '../src/v2/index.ts'
import type { CommandDraft, CommandInfo, PluginContextV2 } from '../src/v2/types.ts'
import type {
CommandDefinition,
CommandDraft,
PluginContextV2,
ToolDraft,
ToolInfoV2,
} from '../src/v2/types.ts'

describe('OpenCode V2 Live Integration', () => {
afterEach(() => {
Expand All @@ -26,42 +32,67 @@ describe('OpenCode V2 Live Integration', () => {
})

it('executes setup inside a simulated OpenCode V2 PluginPromise host', async () => {
const registeredCommands: Record<string, CommandInfo> = {}
const registeredCommands: Record<string, CommandDefinition> = {}
const registeredTools: Record<string, ToolInfoV2> = {}

// Simulated V2 Command Draft from OpenCode core
// Simulated V2 drafts from OpenCode core. Note both editors expose `add()`
// only — there is no `update()` (that mismatch is why tools/commands were
// silently missing before).
const commandDraft: CommandDraft = {
update: (name: string, updateFn: (cmd: CommandInfo) => void) => {
const item: CommandInfo = {}
registeredCommands[name] = item
updateFn(item)
add: (command) => {
registeredCommands[command.name] = command
},
}
const toolDraft: ToolDraft = {
add: (tool) => {
registeredTools[tool.name] = tool
},
}

let transformCalled = false
let commandTransformCalled = false
let toolTransformCalled = false
const simulatedContext: PluginContextV2 = {
options: {
port: 48999,
hostname: '127.0.0.1',
},
command: {
transform: async (callback) => {
transformCalled = true
commandTransformCalled = true
await callback(commandDraft)
},
reload: async () => {},
},
tool: {
transform: async (callback) => {
toolTransformCalled = true
await callback(toolDraft)
},
reload: async () => {},
},
}

// Run setup through V2 plugin contract
await Plugin.setup(simulatedContext)

expect(transformCalled).toBe(true)
expect(toolTransformCalled).toBe(true)
expect(Object.keys(registeredTools).sort()).toEqual([
'pty_kill',
'pty_list',
'pty_read',
'pty_spawn',
'pty_write',
])

expect(commandTransformCalled).toBe(true)
expect(registeredCommands[PTY_OPEN_CLIENT_COMMAND]?.description).toBe(
'Open PTY Sessions Web Interface'
)
expect(typeof registeredCommands[PTY_OPEN_CLIENT_COMMAND]?.execute).toBe('function')
expect(registeredCommands[PTY_SHOW_SERVER_URL_COMMAND]?.description).toBe(
'Show PTY Sessions Web Interface URL'
)
expect(typeof registeredCommands[PTY_SHOW_SERVER_URL_COMMAND]?.execute).toBe('function')

// Verify server creation with V2 options
const server = await getOrCreateServer({
Expand Down
67 changes: 60 additions & 7 deletions test/v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@ import {
ptyTools,
stopActiveServer,
} from '../src/v2/index.ts'
import type { CommandDraft, CommandInfo, PluginContextV2 } from '../src/v2/types.ts'
import type {
CommandDefinition,
CommandDraft,
PluginContextV2,
ToolDraft,
ToolInfoV2,
} from '../src/v2/types.ts'

describe('OpenCode V2 Plugin API', () => {
afterEach(() => {
Expand All @@ -31,15 +37,60 @@ describe('OpenCode V2 Plugin API', () => {
})
})

describe('Tool Registration via ctx.tool.transform', () => {
it('registers every PTY tool with name, input schema and execute', async () => {
const registeredTools: Record<string, ToolInfoV2> = {}

const draft: ToolDraft = {
add: (tool) => {
registeredTools[tool.name] = tool
},
}

const mockTransform = mock(async (callback: (draft: ToolDraft) => void) => {
callback(draft)
})

const ctx: PluginContextV2 = {
options: {},
tool: {
transform: mockTransform,
},
}

await Plugin.setup(ctx)

expect(mockTransform).toHaveBeenCalled()
expect(Object.keys(registeredTools).sort()).toEqual([
'pty_kill',
'pty_list',
'pty_read',
'pty_spawn',
'pty_write',
])

for (const tool of Object.values(registeredTools)) {
expect(typeof tool.description).toBe('string')
expect(tool.description.length).toBeGreaterThan(0)
expect(tool.input).toBeDefined()
expect(typeof tool.execute).toBe('function')
}
})

it('does not fail when the tool transform is unavailable', async () => {
const ctx: PluginContextV2 = { options: {} }
await Plugin.setup(ctx)
expect(getActiveServer()).toBeNull()
})
})

describe('Command Registration via ctx.command.transform', () => {
it('registers slash commands in the command draft', async () => {
const registeredCommands: Record<string, CommandInfo> = {}
it('registers slash commands with an execute handler', async () => {
const registeredCommands: Record<string, CommandDefinition> = {}

const draft: CommandDraft = {
update: (name: string, updateFn: (cmd: CommandInfo) => void) => {
const entry: CommandInfo = {}
registeredCommands[name] = entry
updateFn(entry)
add: (command) => {
registeredCommands[command.name] = command
},
}

Expand All @@ -60,9 +111,11 @@ describe('OpenCode V2 Plugin API', () => {
expect(registeredCommands[PTY_OPEN_CLIENT_COMMAND]?.description).toBe(
'Open PTY Sessions Web Interface'
)
expect(typeof registeredCommands[PTY_OPEN_CLIENT_COMMAND]?.execute).toBe('function')
expect(registeredCommands[PTY_SHOW_SERVER_URL_COMMAND]?.description).toBe(
'Show PTY Sessions Web Interface URL'
)
expect(typeof registeredCommands[PTY_SHOW_SERVER_URL_COMMAND]?.execute).toBe('function')
})
})

Expand Down