opencode-drive 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +90 -0
- package/bin/opencode-drive +2 -0
- package/package.json +45 -0
- package/src/cli/commands.ts +175 -0
- package/src/cli/describe.ts +14 -0
- package/src/cli/driver-runner.ts +39 -0
- package/src/cli/driver.ts +28 -0
- package/src/cli/index.ts +158 -0
- package/src/cli/instance.ts +218 -0
- package/src/cli/parse.ts +24 -0
- package/src/cli/registry.ts +120 -0
- package/src/cli/send.ts +48 -0
- package/src/cli/start.ts +56 -0
- package/src/cli/types.ts +46 -0
- package/src/client/backend.ts +184 -0
- package/src/client/client.ts +252 -0
- package/src/client/index.ts +17 -0
- package/src/client/protocol.ts +186 -0
- package/src/experimental/campaign-api.ts +34 -0
- package/src/experimental/campaign.ts +144 -0
- package/src/experimental/cli-campaign.ts +179 -0
- package/src/experimental/drive.ts +37 -0
- package/src/experimental/driver.ts +41 -0
- package/src/experimental/flow-driver.ts +189 -0
- package/src/experimental/flows/generate.ts +278 -0
- package/src/experimental/flows/index.ts +6 -0
- package/src/experimental/flows/properties.ts +51 -0
- package/src/experimental/flows/types.ts +47 -0
- package/src/experimental/flows/weights.ts +198 -0
- package/src/experimental/generators/config.ts +240 -0
- package/src/experimental/generators/filesystem.ts +95 -0
- package/src/experimental/generators/generate.ts +32 -0
- package/src/experimental/generators/index.ts +10 -0
- package/src/experimental/generators/initial-state.ts +37 -0
- package/src/experimental/generators/random.ts +35 -0
- package/src/experimental/hello-driver.ts +42 -0
- package/src/experimental/index.ts +3 -0
- package/src/experimental/model/derive.ts +119 -0
- package/src/experimental/model/index.ts +2 -0
- package/src/experimental/model/model.ts +42 -0
- package/src/experimental/opencode-simulation.ts +1 -0
- package/src/experimental/reproduce-stale-running-visible.ts +60 -0
- package/src/experimental/reproduce-stale-running.ts +26 -0
- package/src/experimental/stale-running-driver.ts +74 -0
- package/src/index.ts +1 -0
package/README.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# opencode-drive
|
|
2
|
+
|
|
3
|
+
Drive visible, headless, simulated, or real OpenCode instances.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
bunx opencode-drive start --name demo
|
|
7
|
+
|
|
8
|
+
bunx opencode-drive send --name demo \
|
|
9
|
+
--command.type "hello" \
|
|
10
|
+
--command.press enter \
|
|
11
|
+
--command.render \
|
|
12
|
+
--command.state
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`start` launches a detached headless simulated OpenCode process. Add
|
|
16
|
+
`--visible` to keep it in the foreground and show it in the terminal. Pass a custom OpenCode command after
|
|
17
|
+
`--`:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
bunx opencode-drive start --name local --visible -- \
|
|
21
|
+
opencode2 --standalone
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Use `--dev` to run an OpenCode development checkout. The launcher installs the
|
|
25
|
+
checkout's `@opentui/solid` runtime in its isolated working directory and configures
|
|
26
|
+
Bun automatically:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
bunx opencode-drive start --visible --dev ~/projects/opencode-latest
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Both `start` and `send` accept `--driver ./driver.ts`. Drivers may default
|
|
33
|
+
export a function created with `defineDriver`:
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
import { defineDriver } from "opencode-drive/experimental"
|
|
37
|
+
|
|
38
|
+
export default defineDriver(async ({ ui }) => {
|
|
39
|
+
await ui.typeText("hello")
|
|
40
|
+
await ui.pressEnter()
|
|
41
|
+
const state = await ui.state()
|
|
42
|
+
if (!state.focused.editor) throw new Error("prompt editor is not focused")
|
|
43
|
+
})
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Experimental campaign modules use `defineCampaign` from `opencode-drive/experimental`. Every case gets a fresh isolated,
|
|
47
|
+
headless OpenCode process. One deterministic case can use the same runner in a
|
|
48
|
+
visible terminal:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
bunx opencode-drive start --campaign ./campaign.ts --seed 42000
|
|
52
|
+
bunx opencode-drive start --campaign ./campaign.ts --seed 42000 --case 17 --visible
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
OpenCode starts its drive interfaces when `OPENCODE_DRIVE` contains an instance
|
|
56
|
+
name and its simulated services when `OPENCODE_SIMULATE=1`. `run` creates the
|
|
57
|
+
named registry manifest, then sets both variables. OpenCode resolves the
|
|
58
|
+
manifest by name to obtain its drive ports. The manifest has this contract:
|
|
59
|
+
|
|
60
|
+
```json
|
|
61
|
+
{
|
|
62
|
+
"version": 1,
|
|
63
|
+
"name": "demo",
|
|
64
|
+
"pid": 1234,
|
|
65
|
+
"startedAt": "2026-07-06T00:00:00.000Z",
|
|
66
|
+
"mode": "simulated",
|
|
67
|
+
"cwd": "/workspace",
|
|
68
|
+
"artifacts": "/tmp/opencode-drive/demo",
|
|
69
|
+
"endpoints": {
|
|
70
|
+
"ui": "ws://127.0.0.1:41000",
|
|
71
|
+
"backend": "ws://127.0.0.1:41001"
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Probe Experiments
|
|
77
|
+
|
|
78
|
+
Model-based and deterministic simulation drivers for the local opencode V2 TUI
|
|
79
|
+
and server. The probe controls the real application through simulation-only
|
|
80
|
+
WebSocket interfaces while external model and filesystem state remain isolated.
|
|
81
|
+
|
|
82
|
+
The opencode checkout is expected at `~/projects/opencode-latest`.
|
|
83
|
+
|
|
84
|
+
## Setup
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
cd ~/projects/opencode-drive
|
|
88
|
+
bun install
|
|
89
|
+
bun run check
|
|
90
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/package.json",
|
|
3
|
+
"name": "opencode-drive",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "Drive real and simulated OpenCode instances",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"packageManager": "bun@1.3.14",
|
|
9
|
+
"bin": {
|
|
10
|
+
"opencode-drive": "./bin/opencode-drive"
|
|
11
|
+
},
|
|
12
|
+
"exports": {
|
|
13
|
+
".": "./src/index.ts",
|
|
14
|
+
"./client": "./src/client/index.ts",
|
|
15
|
+
"./experimental": "./src/experimental/index.ts"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"bin/opencode-drive",
|
|
19
|
+
"src/cli",
|
|
20
|
+
"src/client",
|
|
21
|
+
"src/experimental",
|
|
22
|
+
"src/index.ts",
|
|
23
|
+
"README.md"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"drive": "bun run src/cli/index.ts",
|
|
27
|
+
"lint": "oxlint src",
|
|
28
|
+
"test": "bun test test/cli/parse.test.ts test/cli/integration.test.ts",
|
|
29
|
+
"typecheck": "tsgo --noEmit",
|
|
30
|
+
"check": "bun run lint && bun run typecheck"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@effect/platform-node": "4.0.0-beta.93",
|
|
34
|
+
"@effect/platform-node-shared": "4.0.0-beta.93",
|
|
35
|
+
"effect": "4.0.0-beta.93"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@tsconfig/bun": "1.0.9",
|
|
39
|
+
"@types/bun": "1.3.13",
|
|
40
|
+
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
|
41
|
+
"oxlint": "1.60.0",
|
|
42
|
+
"oxlint-tsgolint": "0.21.0",
|
|
43
|
+
"typescript": "5.8.2"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { BackendFinishReason, BackendItem, KeyModifiers } from "../client/index.js"
|
|
2
|
+
import { connectBackendSimulation, connectSimulation } from "../client/index.js"
|
|
3
|
+
import type { DriveCommand, InstanceManifest } from "./types.js"
|
|
4
|
+
|
|
5
|
+
const noValue = new Set([
|
|
6
|
+
"render",
|
|
7
|
+
"state",
|
|
8
|
+
"start-record",
|
|
9
|
+
"end-record",
|
|
10
|
+
"enter",
|
|
11
|
+
"llm.pending",
|
|
12
|
+
"network.log",
|
|
13
|
+
])
|
|
14
|
+
|
|
15
|
+
const withValue = new Set([
|
|
16
|
+
"type",
|
|
17
|
+
"press",
|
|
18
|
+
"arrow",
|
|
19
|
+
"focus",
|
|
20
|
+
"click",
|
|
21
|
+
"llm.respond",
|
|
22
|
+
"llm.chunk",
|
|
23
|
+
"llm.finish",
|
|
24
|
+
"llm.disconnect",
|
|
25
|
+
])
|
|
26
|
+
|
|
27
|
+
export function commandAcceptsValue(operation: string) {
|
|
28
|
+
if (noValue.has(operation)) return false
|
|
29
|
+
if (withValue.has(operation)) return true
|
|
30
|
+
throw new Error(`unknown drive command "${operation}"`)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function commandNames() {
|
|
34
|
+
return [...noValue, ...withValue].sort()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function executeCommands(manifest: InstanceManifest, commands: ReadonlyArray<DriveCommand>) {
|
|
38
|
+
const clients: {
|
|
39
|
+
ui?: Awaited<ReturnType<typeof connectSimulation>>
|
|
40
|
+
backend?: Awaited<ReturnType<typeof connectBackendSimulation>>
|
|
41
|
+
} = {}
|
|
42
|
+
const ui = async () => (clients.ui ??= await connectSimulation({ url: manifest.endpoints.ui }))
|
|
43
|
+
const backend = async () => (clients.backend ??= await connectBackendSimulation({ url: manifest.endpoints.backend }))
|
|
44
|
+
const results: Array<{ readonly command: string; readonly result: unknown }> = []
|
|
45
|
+
try {
|
|
46
|
+
for (const command of commands) {
|
|
47
|
+
results.push({ command: command.operation, result: await execute(command, ui, backend) })
|
|
48
|
+
}
|
|
49
|
+
return { name: manifest.name, results }
|
|
50
|
+
} catch (error) {
|
|
51
|
+
throw new CommandBatchError(manifest.name, results, error)
|
|
52
|
+
} finally {
|
|
53
|
+
clients.ui?.close()
|
|
54
|
+
clients.backend?.close()
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class CommandBatchError extends Error {
|
|
59
|
+
constructor(
|
|
60
|
+
readonly instance: string,
|
|
61
|
+
readonly results: ReadonlyArray<{ readonly command: string; readonly result: unknown }>,
|
|
62
|
+
readonly reason: unknown,
|
|
63
|
+
) {
|
|
64
|
+
super(reason instanceof Error ? reason.message : String(reason))
|
|
65
|
+
this.name = "CommandBatchError"
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function execute(
|
|
70
|
+
command: DriveCommand,
|
|
71
|
+
ui: () => Promise<Awaited<ReturnType<typeof connectSimulation>>>,
|
|
72
|
+
backend: () => Promise<Awaited<ReturnType<typeof connectBackendSimulation>>>,
|
|
73
|
+
) {
|
|
74
|
+
switch (command.operation) {
|
|
75
|
+
case "render": return (await ui()).render()
|
|
76
|
+
case "state": return (await ui()).state()
|
|
77
|
+
case "start-record": return (await ui()).startRecord()
|
|
78
|
+
case "end-record": return (await ui()).endRecord()
|
|
79
|
+
case "type": return (await ui()).typeText(required(command))
|
|
80
|
+
case "press": {
|
|
81
|
+
const value = required(command)
|
|
82
|
+
if (!value.trim().startsWith("{")) return value === "enter" ? (await ui()).pressEnter() : (await ui()).pressKey(value)
|
|
83
|
+
const input = object(value)
|
|
84
|
+
return (await ui()).pressKey(string(input, "key"), modifiers(input.modifiers))
|
|
85
|
+
}
|
|
86
|
+
case "enter": return (await ui()).pressEnter()
|
|
87
|
+
case "arrow": {
|
|
88
|
+
const direction = required(command)
|
|
89
|
+
if (direction !== "up" && direction !== "down" && direction !== "left" && direction !== "right") {
|
|
90
|
+
throw new Error("arrow must be up, down, left, or right")
|
|
91
|
+
}
|
|
92
|
+
return (await ui()).pressArrow(direction)
|
|
93
|
+
}
|
|
94
|
+
case "focus": return (await ui()).focus(number(required(command), "target"))
|
|
95
|
+
case "click": {
|
|
96
|
+
const input = object(required(command))
|
|
97
|
+
return (await ui()).click(number(input.target, "target"), number(input.x, "x"), number(input.y, "y"))
|
|
98
|
+
}
|
|
99
|
+
case "llm.pending": return (await backend()).pendingExchanges()
|
|
100
|
+
case "llm.respond": {
|
|
101
|
+
const input = object(required(command))
|
|
102
|
+
const client = await backend()
|
|
103
|
+
const chunk = await client.chunk(string(input, "id"), [{ type: "textDelta", text: string(input, "text") }])
|
|
104
|
+
const finish = await client.finish(string(input, "id"), finishReason(input.reason))
|
|
105
|
+
return { chunk, finish }
|
|
106
|
+
}
|
|
107
|
+
case "llm.chunk": {
|
|
108
|
+
const input = object(required(command))
|
|
109
|
+
if (!Array.isArray(input.items)) throw new Error("llm.chunk items must be an array")
|
|
110
|
+
return (await backend()).chunk(string(input, "id"), input.items as ReadonlyArray<BackendItem>)
|
|
111
|
+
}
|
|
112
|
+
case "llm.finish": {
|
|
113
|
+
const input = scalarOrObject(required(command), "id")
|
|
114
|
+
return (await backend()).finish(string(input, "id"), finishReason(input.reason))
|
|
115
|
+
}
|
|
116
|
+
case "llm.disconnect": return (await backend()).disconnect(required(command))
|
|
117
|
+
case "network.log": return (await backend()).networkLog()
|
|
118
|
+
}
|
|
119
|
+
throw new Error(`unknown drive command "${command.operation}"`)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function required(command: DriveCommand) {
|
|
123
|
+
if (command.value === undefined) throw new Error(`${command.operation} requires a value`)
|
|
124
|
+
return command.value
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function object(value: string) {
|
|
128
|
+
const parsed: unknown = JSON.parse(value)
|
|
129
|
+
if (!isRecord(parsed)) throw new Error("command value must be a JSON object")
|
|
130
|
+
return parsed
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function scalarOrObject(value: string, key: string) {
|
|
134
|
+
return value.trim().startsWith("{") ? object(value) : { [key]: value }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function string(input: Record<string, unknown>, key: string) {
|
|
138
|
+
const value = input[key]
|
|
139
|
+
if (typeof value !== "string") throw new Error(`${key} must be a string`)
|
|
140
|
+
return value
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function number(value: unknown, name: string) {
|
|
144
|
+
const parsed = typeof value === "number" ? value : Number(value)
|
|
145
|
+
if (!Number.isFinite(parsed)) throw new Error(`${name} must be a number`)
|
|
146
|
+
return parsed
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function modifiers(value: unknown): KeyModifiers | undefined {
|
|
150
|
+
if (value === undefined) return undefined
|
|
151
|
+
if (!isRecord(value)) throw new Error("modifiers must be an object")
|
|
152
|
+
return {
|
|
153
|
+
ctrl: optionalBoolean(value.ctrl, "ctrl"),
|
|
154
|
+
shift: optionalBoolean(value.shift, "shift"),
|
|
155
|
+
meta: optionalBoolean(value.meta, "meta"),
|
|
156
|
+
super: optionalBoolean(value.super, "super"),
|
|
157
|
+
hyper: optionalBoolean(value.hyper, "hyper"),
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function finishReason(value: unknown): BackendFinishReason | undefined {
|
|
162
|
+
if (value === undefined) return undefined
|
|
163
|
+
if (value === "stop" || value === "tool-calls" || value === "length" || value === "content-filter") return value
|
|
164
|
+
throw new Error("reason must be stop, tool-calls, length, or content-filter")
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
168
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function optionalBoolean(value: unknown, name: string) {
|
|
172
|
+
if (value === undefined) return undefined
|
|
173
|
+
if (typeof value !== "boolean") throw new Error(`${name} must be a boolean`)
|
|
174
|
+
return value
|
|
175
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { join } from "node:path"
|
|
2
|
+
import { resolveInstance } from "./registry.js"
|
|
3
|
+
|
|
4
|
+
export async function describe(name?: string) {
|
|
5
|
+
const manifest = await resolveInstance(name ?? "default")
|
|
6
|
+
console.log([
|
|
7
|
+
`PID: ${manifest.pid}`,
|
|
8
|
+
`Headless: ${manifest.headless}`,
|
|
9
|
+
`Artifacts: ${manifest.artifacts}`,
|
|
10
|
+
`UI: ${manifest.endpoints.ui}`,
|
|
11
|
+
`Backend: ${manifest.endpoints.backend}`,
|
|
12
|
+
`Logs: ${join(manifest.artifacts, "home", ".local", "share", "opencode", "log", "opencode*.log")}`,
|
|
13
|
+
].join("\n"))
|
|
14
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { resolve } from "node:path"
|
|
2
|
+
import { pathToFileURL } from "node:url"
|
|
3
|
+
import { connectDrive } from "../experimental/drive.js"
|
|
4
|
+
import type { Driver } from "../experimental/drive.js"
|
|
5
|
+
|
|
6
|
+
const driver = process.argv[2]
|
|
7
|
+
if (!driver) throw new Error("driver file is required")
|
|
8
|
+
const name = requiredArgument(3, "instance name")
|
|
9
|
+
const ui = requiredArgument(4, "frontend WebSocket URL")
|
|
10
|
+
const backend = requiredArgument(5, "backend WebSocket URL")
|
|
11
|
+
const artifacts = requiredArgument(6, "artifacts directory")
|
|
12
|
+
|
|
13
|
+
const module: { readonly default?: unknown } = await import(pathToFileURL(resolve(driver)).href)
|
|
14
|
+
if (!isDriver(module.default)) throw new Error("driver must default-export defineDriver(...)")
|
|
15
|
+
const controller = new AbortController()
|
|
16
|
+
process.once("SIGINT", () => controller.abort())
|
|
17
|
+
process.once("SIGTERM", () => controller.abort())
|
|
18
|
+
const session = await connectDrive({ ui, backend })
|
|
19
|
+
try {
|
|
20
|
+
await module.default({
|
|
21
|
+
name,
|
|
22
|
+
ui: session.ui,
|
|
23
|
+
backend: session.backend,
|
|
24
|
+
artifacts,
|
|
25
|
+
signal: controller.signal,
|
|
26
|
+
})
|
|
27
|
+
} finally {
|
|
28
|
+
session.close()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isDriver(value: unknown): value is Driver {
|
|
32
|
+
return typeof value === "function"
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function requiredArgument(index: number, name: string) {
|
|
36
|
+
const value = process.argv[index]
|
|
37
|
+
if (!value) throw new Error(`${name} is required`)
|
|
38
|
+
return value
|
|
39
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises"
|
|
2
|
+
import { resolve } from "node:path"
|
|
3
|
+
import type { InstanceManifest } from "./types.js"
|
|
4
|
+
|
|
5
|
+
export async function runDriver(driver: string, manifest: InstanceManifest) {
|
|
6
|
+
await mkdir(manifest.artifacts, { recursive: true })
|
|
7
|
+
const child = Bun.spawn([
|
|
8
|
+
process.execPath,
|
|
9
|
+
resolve(import.meta.dir, "driver-runner.ts"),
|
|
10
|
+
resolve(driver),
|
|
11
|
+
manifest.name,
|
|
12
|
+
manifest.endpoints.ui,
|
|
13
|
+
manifest.endpoints.backend,
|
|
14
|
+
manifest.artifacts,
|
|
15
|
+
], {
|
|
16
|
+
cwd: process.cwd(),
|
|
17
|
+
env: cleanEnv(process.env),
|
|
18
|
+
stdin: "inherit",
|
|
19
|
+
stdout: "inherit",
|
|
20
|
+
stderr: "inherit",
|
|
21
|
+
})
|
|
22
|
+
const status = await child.exited
|
|
23
|
+
if (status !== 0) throw new Error(`driver exited with status ${status}`)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function cleanEnv(env: Readonly<Record<string, string | undefined>>) {
|
|
27
|
+
return Object.fromEntries(Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined))
|
|
28
|
+
}
|
package/src/cli/index.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { NodeRuntime, NodeServices } from "@effect/platform-node"
|
|
3
|
+
import { Effect, Option } from "effect"
|
|
4
|
+
import { Command, Flag } from "effect/unstable/cli"
|
|
5
|
+
import { send } from "./send.js"
|
|
6
|
+
import { describe } from "./describe.js"
|
|
7
|
+
import { extractCommands } from "./parse.js"
|
|
8
|
+
import { start } from "./start.js"
|
|
9
|
+
import type { DriveCommand, SendOptions, StartOptions } from "./types.js"
|
|
10
|
+
|
|
11
|
+
const extracted = extract()
|
|
12
|
+
const name = Flag.string("name").pipe(Flag.optional, Flag.withDescription("Instance name"))
|
|
13
|
+
const driver = Flag.string("driver").pipe(Flag.optional, Flag.withDescription("TypeScript driver module"))
|
|
14
|
+
|
|
15
|
+
const startCommand = Command.make("start", {
|
|
16
|
+
name,
|
|
17
|
+
driver,
|
|
18
|
+
campaign: Flag.string("campaign").pipe(Flag.optional, Flag.withDescription("Campaign module")),
|
|
19
|
+
seed: Flag.integer("seed").pipe(Flag.withDefault(Date.now() % 1_000_000)),
|
|
20
|
+
caseIndex: Flag.integer("case").pipe(
|
|
21
|
+
Flag.filter((value) => value >= 0, () => "Case must be non-negative"),
|
|
22
|
+
Flag.optional,
|
|
23
|
+
),
|
|
24
|
+
count: Flag.integer("count").pipe(
|
|
25
|
+
Flag.filter((value) => value > 0, () => "Count must be greater than zero"),
|
|
26
|
+
Flag.optional,
|
|
27
|
+
),
|
|
28
|
+
concurrency: Flag.integer("concurrency").pipe(
|
|
29
|
+
Flag.filter((value) => value > 0, () => "Concurrency must be greater than zero"),
|
|
30
|
+
Flag.withDefault(1),
|
|
31
|
+
),
|
|
32
|
+
visible: Flag.boolean("visible").pipe(Flag.withDescription("Show OpenCode in the terminal")),
|
|
33
|
+
detach: Flag.boolean("detach").pipe(Flag.withDescription("Keep OpenCode running in the background (default)")),
|
|
34
|
+
dev: Flag.string("dev").pipe(Flag.optional, Flag.withDescription("Path to an OpenCode development checkout")),
|
|
35
|
+
state: Flag.string("state").pipe(Flag.optional, Flag.withDescription("Simulation snapshot containing files/")),
|
|
36
|
+
anchor: Flag.string("anchor").pipe(Flag.optional),
|
|
37
|
+
}, (config) => execute(() => start(toStartOptions(config, extracted.commands, extracted.app)))).pipe(
|
|
38
|
+
Command.withDescription("Launch and own a local simulated OpenCode instance"),
|
|
39
|
+
Command.withExamples([
|
|
40
|
+
{ command: "opencode-drive start --name demo --visible", description: "Launch a visible simulated instance" },
|
|
41
|
+
{ command: "opencode-drive start --name demo --detach", description: "Launch a background simulated instance" },
|
|
42
|
+
]),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
const sendCommand = Command.make("send", { name, driver }, (config) =>
|
|
46
|
+
execute(() => send(toSendOptions(config, extracted.commands, extracted.app)))).pipe(
|
|
47
|
+
Command.withDescription("Connect to an existing drive-enabled OpenCode instance"),
|
|
48
|
+
Command.withExamples([
|
|
49
|
+
{
|
|
50
|
+
command: "opencode-drive send --name demo --command.type hello --command.press enter --command.render --command.state",
|
|
51
|
+
description: "Execute an ordered command batch",
|
|
52
|
+
},
|
|
53
|
+
]),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
const describeCommand = Command.make("describe", { name }, (config) =>
|
|
57
|
+
execute(() => describe(Option.getOrUndefined(config.name)))).pipe(
|
|
58
|
+
Command.withDescription("Describe a registered OpenCode instance"),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
const root = Command.make("opencode-drive").pipe(
|
|
62
|
+
Command.withDescription("Drive real and simulated OpenCode instances"),
|
|
63
|
+
Command.withSubcommands([startCommand, sendCommand, describeCommand]),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
Command.runWith(root, { version: "0.1.0" })(extracted.args).pipe(
|
|
67
|
+
Effect.provide(NodeServices.layer),
|
|
68
|
+
NodeRuntime.runMain,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
function toStartOptions(
|
|
72
|
+
config: {
|
|
73
|
+
readonly name: Option.Option<string>
|
|
74
|
+
readonly driver: Option.Option<string>
|
|
75
|
+
readonly campaign: Option.Option<string>
|
|
76
|
+
readonly seed: number
|
|
77
|
+
readonly caseIndex: Option.Option<number>
|
|
78
|
+
readonly count: Option.Option<number>
|
|
79
|
+
readonly concurrency: number
|
|
80
|
+
readonly visible: boolean
|
|
81
|
+
readonly detach: boolean
|
|
82
|
+
readonly dev: Option.Option<string>
|
|
83
|
+
readonly state: Option.Option<string>
|
|
84
|
+
readonly anchor: Option.Option<string>
|
|
85
|
+
},
|
|
86
|
+
commands: ReadonlyArray<DriveCommand>,
|
|
87
|
+
app: ReadonlyArray<string>,
|
|
88
|
+
): StartOptions {
|
|
89
|
+
const driver = Option.getOrUndefined(config.driver)
|
|
90
|
+
const campaign = Option.getOrUndefined(config.campaign)
|
|
91
|
+
const visible = config.visible
|
|
92
|
+
const options = {
|
|
93
|
+
kind: "start" as const,
|
|
94
|
+
name: Option.getOrUndefined(config.name),
|
|
95
|
+
driver,
|
|
96
|
+
campaign,
|
|
97
|
+
seed: config.seed,
|
|
98
|
+
caseIndex: Option.getOrUndefined(config.caseIndex),
|
|
99
|
+
count: Option.getOrUndefined(config.count),
|
|
100
|
+
concurrency: config.concurrency,
|
|
101
|
+
visible,
|
|
102
|
+
detach: !visible && (config.detach || (driver === undefined && campaign === undefined && commands.length === 0)),
|
|
103
|
+
dev: Option.getOrUndefined(config.dev),
|
|
104
|
+
state: Option.getOrUndefined(config.state),
|
|
105
|
+
anchor: Option.getOrUndefined(config.anchor),
|
|
106
|
+
command: app,
|
|
107
|
+
commands,
|
|
108
|
+
}
|
|
109
|
+
assertExecutionMode(options.driver, options.campaign, commands)
|
|
110
|
+
if (options.dev !== undefined && app.length > 0) throw new Error("--dev cannot be combined with a command after --")
|
|
111
|
+
if (options.caseIndex !== undefined && options.campaign === undefined) throw new Error("--case requires --campaign")
|
|
112
|
+
if (options.visible && options.campaign !== undefined && options.caseIndex === undefined) {
|
|
113
|
+
throw new Error("visible campaign runs require --case")
|
|
114
|
+
}
|
|
115
|
+
if (config.detach && (options.driver !== undefined || options.campaign !== undefined || commands.length > 0)) {
|
|
116
|
+
throw new Error("--detach cannot be combined with --driver, --campaign, or command flags")
|
|
117
|
+
}
|
|
118
|
+
return options
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function toSendOptions(
|
|
122
|
+
config: { readonly name: Option.Option<string>; readonly driver: Option.Option<string> },
|
|
123
|
+
commands: ReadonlyArray<DriveCommand>,
|
|
124
|
+
app: ReadonlyArray<string>,
|
|
125
|
+
): SendOptions {
|
|
126
|
+
if (app.length > 0) throw new Error("send does not accept a command after --")
|
|
127
|
+
const options = {
|
|
128
|
+
kind: "send" as const,
|
|
129
|
+
name: Option.getOrUndefined(config.name),
|
|
130
|
+
driver: Option.getOrUndefined(config.driver),
|
|
131
|
+
commands,
|
|
132
|
+
}
|
|
133
|
+
assertExecutionMode(options.driver, undefined, commands)
|
|
134
|
+
return options
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function assertExecutionMode(driver: string | undefined, campaign: string | undefined, commands: ReadonlyArray<DriveCommand>) {
|
|
138
|
+
const count = Number(driver !== undefined) + Number(campaign !== undefined) + Number(commands.length > 0)
|
|
139
|
+
if (count > 1) throw new Error("command flags, --driver, and --campaign are mutually exclusive")
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function execute(task: () => Promise<void>) {
|
|
143
|
+
return Effect.tryPromise({ try: task, catch: (error) => error }).pipe(
|
|
144
|
+
Effect.catch((error) => Effect.sync(() => {
|
|
145
|
+
console.error(`error: ${error instanceof Error ? error.message : String(error)}`)
|
|
146
|
+
process.exitCode = 1
|
|
147
|
+
})),
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function extract() {
|
|
152
|
+
try {
|
|
153
|
+
return extractCommands(process.argv.slice(2))
|
|
154
|
+
} catch (error) {
|
|
155
|
+
console.error(`opencode-drive: ${error instanceof Error ? error.message : String(error)}`)
|
|
156
|
+
return process.exit(1)
|
|
157
|
+
}
|
|
158
|
+
}
|