mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-30 07:01:23 -04:00
Compare commits
3 Commits
project-picker
...
v2
| Author | SHA1 | Date | |
|---|---|---|---|
| f9de608dea | |||
| 906dc8f5b2 | |||
| 12971935ab |
@@ -143,7 +143,10 @@
|
||||
"open": "10.1.2",
|
||||
"semver": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
"uqr": "0.1.3",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"ws": "8.21.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -402,8 +405,11 @@
|
||||
"immer": "11.1.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"semver": "^7.6.3",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
"turndown": "7.2.0",
|
||||
"venice-ai-sdk-provider": "2.1.1",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"which": "6.0.1",
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -1073,9 +1079,11 @@
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"esbuild",
|
||||
"tree-sitter-powershell",
|
||||
"protobufjs",
|
||||
"electron",
|
||||
"web-tree-sitter",
|
||||
"tree-sitter-bash",
|
||||
],
|
||||
"patchedDependencies": {
|
||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||
@@ -4072,10 +4080,10 @@
|
||||
|
||||
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
|
||||
|
||||
"file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
|
||||
|
||||
"ffi-rs": ["ffi-rs@1.3.2", "", { "optionalDependencies": { "@yuuang/ffi-rs-android-arm64": "1.3.2", "@yuuang/ffi-rs-darwin-arm64": "1.3.2", "@yuuang/ffi-rs-darwin-x64": "1.3.2", "@yuuang/ffi-rs-linux-arm-gnueabihf": "1.3.2", "@yuuang/ffi-rs-linux-arm64-gnu": "1.3.2", "@yuuang/ffi-rs-linux-arm64-musl": "1.3.2", "@yuuang/ffi-rs-linux-x64-gnu": "1.3.2", "@yuuang/ffi-rs-linux-x64-musl": "1.3.2", "@yuuang/ffi-rs-win32-arm64-msvc": "1.3.2", "@yuuang/ffi-rs-win32-ia32-msvc": "1.3.2", "@yuuang/ffi-rs-win32-x64-msvc": "1.3.2" } }, "sha512-4s8dX9VbBw/jd5NOuE3EJRqXaIVdjMyiumeeDzrOhtjQRwp6Bz2za7iksWXTnvTQKV/tTdm1s1w7mObe92zPjQ=="],
|
||||
|
||||
"file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
|
||||
|
||||
"filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="],
|
||||
|
||||
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||
@@ -5634,6 +5642,10 @@
|
||||
|
||||
"traverse": ["traverse@0.3.9", "", {}, "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ=="],
|
||||
|
||||
"tree-sitter-bash": ["tree-sitter-bash@0.25.0", "", { "dependencies": { "node-addon-api": "^8.2.1", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-gZtlj9+qFS81qKxpLfD6H0UssQ3QBc/F0nKkPsiFDyfQF2YBqYvglFJUzchrPpVhZe9kLZTrJ9n2J6lmka69Vg=="],
|
||||
|
||||
"tree-sitter-powershell": ["tree-sitter-powershell@0.25.10", "", { "dependencies": { "node-addon-api": "^7.1.0", "node-gyp-build": "^4.8.0" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-bEt8QoySpGFnU3aa8WedQyNMaN6aTwy/WUbvIVt0JSKF+BbJoSHNHu+wCbhj7xLMsfB0AuffmiJm+B8gzva8Lg=="],
|
||||
|
||||
"treeverse": ["treeverse@3.0.0", "", {}, "sha512-gcANaAnd2QDZFmHFEOF4k7uc1J/6a6z3DJMd/QwEyxLoKGiptJRwid582r7QIsFlFMIZ3SnxfS52S4hm2DHkuQ=="],
|
||||
|
||||
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
|
||||
@@ -6856,6 +6868,8 @@
|
||||
|
||||
"topojson-client/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
|
||||
|
||||
"tree-sitter-bash/node-addon-api": ["node-addon-api@8.9.0", "", {}, "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q=="],
|
||||
|
||||
"tw-to-css/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
||||
|
||||
"tw-to-css/tailwindcss": ["tailwindcss@3.3.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.5.3", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.2.12", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.18.2", "lilconfig": "^2.1.0", "micromatch": "^4.0.5", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.0.0", "postcss": "^8.4.23", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.1", "postcss-nested": "^6.0.1", "postcss-selector-parser": "^6.0.11", "postcss-value-parser": "^4.2.0", "resolve": "^1.22.2", "sucrase": "^3.32.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w=="],
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Framing } from "../route/framing"
|
||||
import { Protocol } from "../route/protocol"
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
|
||||
import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
|
||||
import type { ProviderPackage } from "../provider-package"
|
||||
import * as OpenAICompatibleProfiles from "./openai-compatible-profile"
|
||||
import * as OpenAIChat from "../protocols/openai-chat"
|
||||
import { isRecord } from "../protocols/shared"
|
||||
@@ -30,6 +31,12 @@ export type ModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
readonly providerOptions?: OpenRouterProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: OpenRouterProviderOptionsInput
|
||||
}
|
||||
|
||||
const OpenRouterBody = Schema.StructWithRest(Schema.Struct(OpenAIChat.bodyFields), [
|
||||
Schema.Record(Schema.String, Schema.Any),
|
||||
])
|
||||
@@ -113,4 +120,15 @@ export const configure = (input: ModelOptions = {}) => {
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
export const model = provider.model
|
||||
export const model: ProviderPackage.Definition<Settings, OpenRouterProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers,
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
@@ -6,6 +6,7 @@ import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat"
|
||||
import * as OpenAIResponses from "../protocols/openai-responses"
|
||||
import { XAIImages } from "../protocols/xai-images"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options"
|
||||
import type { ProviderPackage } from "../provider-package"
|
||||
|
||||
export const id = ProviderID.make("xai")
|
||||
|
||||
@@ -15,6 +16,12 @@ export type ModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export type { XAIImageOptions } from "../protocols/xai-images"
|
||||
|
||||
export const routes = [OpenAIResponses.route, OpenAICompatibleChat.route]
|
||||
@@ -65,7 +72,15 @@ export const configure = (input: ModelOptions = {}) => {
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
export const model = provider.model
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers,
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
export const responses = provider.responses
|
||||
export const chat = provider.chat
|
||||
export const image = provider.image
|
||||
|
||||
@@ -53,9 +53,7 @@ describe("public exports", () => {
|
||||
expect(CloudflareWorkersAI.configure).toBeFunction()
|
||||
expect(CloudflareWorkersAI.configure({ accountId: "fixture", apiKey: "fixture" }).model).toBeFunction()
|
||||
expect(OpenRouter.model).toBeFunction()
|
||||
expect(OpenRouter.provider.model).toBe(OpenRouter.model)
|
||||
expect(XAI.model).toBeFunction()
|
||||
expect(XAI.provider.model).toBe(XAI.model)
|
||||
expect(XAI.provider.responses).toBe(XAI.responses)
|
||||
expect(XAI.provider.chat).toBe(XAI.chat)
|
||||
expect(XAI.configure({ apiKey: "fixture" }).responses("grok-4.3").route.id).toBe("openai-responses")
|
||||
|
||||
@@ -21,6 +21,8 @@ describe("provider package entrypoints", () => {
|
||||
import("@opencode-ai/ai/providers/google-vertex/chat"),
|
||||
import("@opencode-ai/ai/providers/google-vertex/responses"),
|
||||
import("@opencode-ai/ai/providers/google-vertex/messages"),
|
||||
import("@opencode-ai/ai/providers/openrouter"),
|
||||
import("@opencode-ai/ai/providers/xai"),
|
||||
])
|
||||
|
||||
for (const module of modules) expect(module.model).toBeFunction()
|
||||
@@ -29,6 +31,35 @@ describe("provider package entrypoints", () => {
|
||||
expect(modules[12].model).toBe(modules[13].model)
|
||||
})
|
||||
|
||||
test("maps OpenRouter and xAI package settings onto executable models", async () => {
|
||||
const OpenRouter = await import("@opencode-ai/ai/providers/openrouter")
|
||||
const XAI = await import("@opencode-ai/ai/providers/xai")
|
||||
const settings = {
|
||||
apiKey: "fixture",
|
||||
baseURL: "https://provider.example.test/v1",
|
||||
headers: { "x-application": "opencode" },
|
||||
body: { service_tier: "priority" },
|
||||
limits: { context: 200_000, output: 64_000 },
|
||||
}
|
||||
const openrouter = OpenRouter.model("anthropic/claude-sonnet-4", {
|
||||
...settings,
|
||||
providerOptions: { openrouter: { usage: true } },
|
||||
})
|
||||
const xai = XAI.model("grok-4", {
|
||||
...settings,
|
||||
providerOptions: { openai: { reasoningEffort: "high" } },
|
||||
})
|
||||
|
||||
for (const selected of [openrouter, xai]) {
|
||||
expect(selected.route.endpoint.baseURL).toBe(settings.baseURL)
|
||||
expect(selected.route.defaults.headers).toEqual(settings.headers)
|
||||
expect(selected.route.defaults.http?.body).toEqual(settings.body)
|
||||
expect(selected.route.defaults.limits).toEqual(settings.limits)
|
||||
}
|
||||
expect(openrouter.route.defaults.providerOptions).toEqual({ openrouter: { usage: true } })
|
||||
expect(xai.route.defaults.providerOptions).toEqual({ openai: { reasoningEffort: "high", store: false } })
|
||||
})
|
||||
|
||||
test("maps package settings onto the executable model", () => {
|
||||
const selected = model("gpt-5", {
|
||||
apiKey: "fixture",
|
||||
|
||||
@@ -40,6 +40,9 @@
|
||||
"open": "10.1.2",
|
||||
"semver": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"uqr": "0.1.3",
|
||||
"ws": "8.21.0"
|
||||
},
|
||||
|
||||
@@ -62,8 +62,8 @@ for (const target of targets) {
|
||||
await rm("dist-node", { recursive: true, force: true })
|
||||
const assetHash = await hashNodeAssets(assets)
|
||||
const input = { version: Script.version, channel: Script.channel, models: modelsData, assetHash, target }
|
||||
await build(mainConfig(input))
|
||||
await copyNodeAssets(assets)
|
||||
await build(mainConfig(input))
|
||||
|
||||
const host = target.platform === process.platform && target.arch === process.arch
|
||||
if (host) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { copyFile, mkdir, readdir, readFile, stat } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { getNodeAssets } from "@opentui/core/node-assets"
|
||||
import { attentionSoundAssets, type NodeTarget, photonWasmAsset } from "../src/node/target"
|
||||
import { attentionSoundAssets, type NodeTarget, photonWasmAsset, shellParserWasmAssets } from "../src/node/target"
|
||||
|
||||
const dir = path.resolve(import.meta.dirname, "..")
|
||||
|
||||
@@ -43,6 +43,10 @@ export async function collectNodeAssets(target: NodeTarget) {
|
||||
key: photonWasmAsset,
|
||||
source: fileURLToPath(import.meta.resolve(photonWasmAsset)),
|
||||
},
|
||||
...Object.values(shellParserWasmAssets).map((key) => ({
|
||||
key,
|
||||
source: fileURLToPath(import.meta.resolve(key)),
|
||||
})),
|
||||
...attentionSoundAssets.map((key) => ({
|
||||
key,
|
||||
source: path.resolve(dir, "../ui/src/assets/audio", path.basename(key)),
|
||||
|
||||
@@ -29,6 +29,11 @@ export function nodeTarget(platform: string, arch: string) {
|
||||
}
|
||||
|
||||
export const photonWasmAsset = "@silvia-odwyer/photon-node/photon_rs_bg.wasm"
|
||||
export const shellParserWasmAssets = {
|
||||
runtime: "web-tree-sitter/tree-sitter.wasm",
|
||||
bash: "tree-sitter-bash/tree-sitter-bash.wasm",
|
||||
powershell: "tree-sitter-powershell/tree-sitter-powershell.wasm",
|
||||
} as const
|
||||
export const nodeExecArgv = ["--experimental-ffi", "--use-system-ca", "--disable-warning=ExperimentalWarning"] as const
|
||||
|
||||
export const attentionSoundAssets = [
|
||||
|
||||
@@ -3,7 +3,13 @@ import { readFile } from "node:fs/promises"
|
||||
import { createRequire } from "node:module"
|
||||
import { defineConfig, type Plugin, type UserConfig } from "vite"
|
||||
import solid from "vite-plugin-solid"
|
||||
import { nodeExecArgv, nodeTarget, type NodeTarget, photonWasmAsset } from "./src/node/target"
|
||||
import {
|
||||
nodeExecArgv,
|
||||
nodeTarget,
|
||||
type NodeTarget,
|
||||
photonWasmAsset,
|
||||
shellParserWasmAssets,
|
||||
} from "./src/node/target"
|
||||
|
||||
const dir = import.meta.dirname
|
||||
|
||||
@@ -194,6 +200,9 @@ process.env.OTUI_ASSET_ROOT = __ocAssetRoot
|
||||
process.env.OPENCODE_NODE_PTY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.nodePtyEntryAsset)})
|
||||
process.env.OPENCODE_PARCEL_WATCHER_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.parcelWatcherAsset)})
|
||||
process.env.OPENCODE_PHOTON_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(photonWasmAsset)})
|
||||
process.env.OPENCODE_TREE_SITTER_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.runtime)})
|
||||
process.env.OPENCODE_TREE_SITTER_BASH_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.bash)})
|
||||
process.env.OPENCODE_TREE_SITTER_POWERSHELL_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.powershell)})
|
||||
process.env.FFF_BINARY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffAsset)})
|
||||
process.env.OPENCODE_FFF_FFI_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffFfiAsset)})
|
||||
try {
|
||||
|
||||
@@ -41,6 +41,11 @@
|
||||
"node": "./src/image/photon-wasm.node.ts",
|
||||
"default": "./src/image/photon-wasm.bun.ts"
|
||||
},
|
||||
"#shell-parser-wasm": {
|
||||
"bun": "./src/shell/parser-wasm.bun.ts",
|
||||
"node": "./src/shell/parser-wasm.node.ts",
|
||||
"default": "./src/shell/parser-wasm.bun.ts"
|
||||
},
|
||||
"#process-lock-ffi": {
|
||||
"bun": "./src/util/process-lock-ffi.bun.ts",
|
||||
"node": "./src/util/process-lock-ffi.node.ts",
|
||||
@@ -119,7 +124,10 @@
|
||||
"jsonc-parser": "3.3.1",
|
||||
"semver": "^7.6.3",
|
||||
"turndown": "7.2.0",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
"venice-ai-sdk-provider": "2.1.1",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"which": "6.0.1",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
export * as ShellParse from "./parse"
|
||||
|
||||
import { Effect } from "effect"
|
||||
import { fileURLToPath } from "url"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import type { Node } from "web-tree-sitter"
|
||||
import { shellParserWasm } from "#shell-parser-wasm"
|
||||
import { ShellSelect } from "./select"
|
||||
|
||||
type Part = { type: string; text: string }
|
||||
const CWD = new Set(["cd", "chdir", "popd", "pushd", "push-location", "set-location"])
|
||||
const POWERSHELL_PATH_FLAGS = new Set(["-literalpath", "-path"])
|
||||
|
||||
const ARITY: Record<string, number> = {
|
||||
cat: 1,
|
||||
cd: 1,
|
||||
chmod: 1,
|
||||
chown: 1,
|
||||
cp: 1,
|
||||
echo: 1,
|
||||
env: 1,
|
||||
export: 1,
|
||||
grep: 1,
|
||||
kill: 1,
|
||||
killall: 1,
|
||||
ln: 1,
|
||||
ls: 1,
|
||||
mkdir: 1,
|
||||
mv: 1,
|
||||
ps: 1,
|
||||
pwd: 1,
|
||||
rm: 1,
|
||||
rmdir: 1,
|
||||
sleep: 1,
|
||||
source: 1,
|
||||
tail: 1,
|
||||
touch: 1,
|
||||
unset: 1,
|
||||
which: 1,
|
||||
aws: 3,
|
||||
az: 3,
|
||||
bazel: 2,
|
||||
brew: 2,
|
||||
bun: 2,
|
||||
"bun run": 3,
|
||||
"bun x": 3,
|
||||
cargo: 2,
|
||||
"cargo add": 3,
|
||||
"cargo run": 3,
|
||||
cdk: 2,
|
||||
cf: 2,
|
||||
cmake: 2,
|
||||
composer: 2,
|
||||
consul: 2,
|
||||
"consul kv": 3,
|
||||
crictl: 2,
|
||||
deno: 2,
|
||||
"deno task": 3,
|
||||
doctl: 3,
|
||||
docker: 2,
|
||||
"docker builder": 3,
|
||||
"docker compose": 3,
|
||||
"docker container": 3,
|
||||
"docker image": 3,
|
||||
"docker network": 3,
|
||||
"docker volume": 3,
|
||||
eksctl: 2,
|
||||
"eksctl create": 3,
|
||||
firebase: 2,
|
||||
flyctl: 2,
|
||||
gcloud: 3,
|
||||
gh: 3,
|
||||
git: 2,
|
||||
"git config": 3,
|
||||
"git remote": 3,
|
||||
"git stash": 3,
|
||||
go: 2,
|
||||
gradle: 2,
|
||||
helm: 2,
|
||||
heroku: 2,
|
||||
hugo: 2,
|
||||
ip: 2,
|
||||
"ip addr": 3,
|
||||
"ip link": 3,
|
||||
"ip netns": 3,
|
||||
"ip route": 3,
|
||||
kind: 2,
|
||||
"kind create": 3,
|
||||
kubectl: 2,
|
||||
"kubectl kustomize": 3,
|
||||
"kubectl rollout": 3,
|
||||
kustomize: 2,
|
||||
make: 2,
|
||||
mc: 2,
|
||||
"mc admin": 3,
|
||||
minikube: 2,
|
||||
mongosh: 2,
|
||||
mysql: 2,
|
||||
mvn: 2,
|
||||
ng: 2,
|
||||
npm: 2,
|
||||
"npm exec": 3,
|
||||
"npm init": 3,
|
||||
"npm run": 3,
|
||||
"npm view": 3,
|
||||
npx: 2,
|
||||
nvm: 2,
|
||||
nx: 2,
|
||||
openssl: 2,
|
||||
"openssl req": 3,
|
||||
"openssl x509": 3,
|
||||
pip: 2,
|
||||
pipenv: 2,
|
||||
pnpm: 2,
|
||||
"pnpm dlx": 3,
|
||||
"pnpm exec": 3,
|
||||
"pnpm run": 3,
|
||||
poetry: 2,
|
||||
podman: 2,
|
||||
"podman container": 3,
|
||||
"podman image": 3,
|
||||
psql: 2,
|
||||
pulumi: 2,
|
||||
"pulumi stack": 3,
|
||||
python: 2,
|
||||
pyenv: 2,
|
||||
rake: 2,
|
||||
rbenv: 2,
|
||||
"redis-cli": 2,
|
||||
rustup: 2,
|
||||
serverless: 2,
|
||||
sfdx: 3,
|
||||
skaffold: 2,
|
||||
sls: 2,
|
||||
sst: 2,
|
||||
swift: 2,
|
||||
systemctl: 2,
|
||||
terraform: 2,
|
||||
"terraform workspace": 3,
|
||||
tmux: 2,
|
||||
turbo: 2,
|
||||
ufw: 2,
|
||||
vault: 2,
|
||||
"vault auth": 3,
|
||||
"vault kv": 3,
|
||||
vercel: 2,
|
||||
volta: 2,
|
||||
wp: 2,
|
||||
yarn: 2,
|
||||
"yarn dlx": 3,
|
||||
"yarn run": 3,
|
||||
}
|
||||
|
||||
export const scan = Effect.fn("ShellParse.scan")(function* (command: string, shell: string, cwd: string) {
|
||||
const parsers = yield* Effect.promise(load)
|
||||
const powershell = ShellSelect.ps(shell)
|
||||
const tree = (powershell ? parsers.ps : parsers.bash).parse(command)
|
||||
if (!tree) return yield* Effect.fail(new Error("Failed to parse shell command"))
|
||||
|
||||
return yield* Effect.acquireUseRelease(
|
||||
Effect.succeed(tree),
|
||||
(tree) =>
|
||||
Effect.sync(() =>
|
||||
tree.rootNode.descendantsOfType("command").reduce(
|
||||
(result, node) => {
|
||||
if (!node) return result
|
||||
const command = parts(node)
|
||||
const tokens = command.map((part) => part.text)
|
||||
if (tokens.length === 0) return result
|
||||
const name = powershell ? tokens[0].toLowerCase() : tokens[0]
|
||||
if (CWD.has(name)) {
|
||||
result.directories.push(...directoryArgs(command, powershell, cwd, shell))
|
||||
return result
|
||||
}
|
||||
result.commands.push({
|
||||
resource: (node.parent?.type === "redirected_statement" ? node.parent.text : node.text).trim(),
|
||||
save: `${prefix(tokens).join(" ")} *`,
|
||||
})
|
||||
return result
|
||||
},
|
||||
{ commands: [] as Array<{ resource: string; save: string }>, directories: [] as string[] },
|
||||
),
|
||||
),
|
||||
(tree) => Effect.sync(() => tree.delete()),
|
||||
)
|
||||
})
|
||||
|
||||
function parts(node: Node) {
|
||||
return Array.from({ length: node.childCount }).flatMap((_, index): Part[] => {
|
||||
const child = node.child(index)
|
||||
if (!child) return []
|
||||
if (child.type === "command_elements")
|
||||
return Array.from({ length: child.childCount }).flatMap((_, itemIndex): Part[] => {
|
||||
const item = child.child(itemIndex)
|
||||
if (!item || item.type === "command_argument_sep" || item.type === "redirection") return []
|
||||
return [{ type: item.type, text: item.text }]
|
||||
})
|
||||
if (!["command_name", "command_name_expr", "word", "string", "raw_string", "concatenation"].includes(child.type))
|
||||
return []
|
||||
return [{ type: child.type, text: child.text }]
|
||||
})
|
||||
}
|
||||
|
||||
function directoryArgs(command: Part[], powershell: boolean, cwd: string, shell: string) {
|
||||
if (!powershell)
|
||||
return command
|
||||
.slice(1)
|
||||
.filter((part) => !part.text.startsWith("-"))
|
||||
.map((part) => directoryArgument(part.text, powershell, cwd, shell))
|
||||
.filter((part) => part !== undefined)
|
||||
|
||||
const directories: string[] = []
|
||||
let path = false
|
||||
for (const part of command.slice(1)) {
|
||||
if (path) {
|
||||
const value = directoryArgument(part.text, powershell, cwd, shell)
|
||||
if (value) directories.push(value)
|
||||
path = false
|
||||
continue
|
||||
}
|
||||
if (part.type === "command_parameter") {
|
||||
path = POWERSHELL_PATH_FLAGS.has(part.text.toLowerCase())
|
||||
continue
|
||||
}
|
||||
const value = directoryArgument(part.text, powershell, cwd, shell)
|
||||
if (value) directories.push(value)
|
||||
}
|
||||
return directories
|
||||
}
|
||||
|
||||
function directoryArgument(value: string, powershell: boolean, cwd: string, shell: string) {
|
||||
const quote = value[0]
|
||||
const text = (quote === '"' || quote === "'") && value.at(-1) === quote ? value.slice(1, -1) : value
|
||||
if (!powershell) return expandKnownDirectory(text)
|
||||
|
||||
// PowerShell exposes environment variables through $env:NAME and provides these
|
||||
// automatic directory variables. Expand only values we can determine without executing code.
|
||||
return expandKnownDirectory(
|
||||
text
|
||||
.replace(/\$\{env:([^}]+)\}/gi, (_, key: string) => environment(key) ?? "")
|
||||
.replace(/\$env:([A-Za-z_][A-Za-z0-9_]*)/gi, (_, key: string) => environment(key) ?? "")
|
||||
.replace(/\$(HOME|PWD|PSHOME)(?=$|[\\/])/gi, (_, key: string) => {
|
||||
if (key.toUpperCase() === "HOME") return os.homedir()
|
||||
if (key.toUpperCase() === "PWD") return cwd
|
||||
return path.dirname(shell)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function expandKnownDirectory(value: string) {
|
||||
// Unknown shell expressions cannot be resolved safely during permission analysis.
|
||||
if (value.includes("$") || value.includes("`") || value.startsWith("(")) return
|
||||
if (value === "~") return os.homedir()
|
||||
if (value.startsWith("~/") || value.startsWith("~\\")) return path.join(os.homedir(), value.slice(2))
|
||||
return value
|
||||
}
|
||||
|
||||
function environment(key: string) {
|
||||
if (process.platform !== "win32") return process.env[key]
|
||||
const name = Object.keys(process.env).find((item) => item.toLowerCase() === key.toLowerCase())
|
||||
return name ? process.env[name] : undefined
|
||||
}
|
||||
|
||||
function prefix(tokens: string[]) {
|
||||
for (let length = tokens.length; length > 0; length--) {
|
||||
const arity = ARITY[tokens.slice(0, length).join(" ")]
|
||||
if (arity !== undefined) return tokens.slice(0, arity)
|
||||
}
|
||||
return tokens.slice(0, 1)
|
||||
}
|
||||
|
||||
function resolve(asset: string) {
|
||||
if (asset.startsWith("file://")) return fileURLToPath(asset)
|
||||
if (path.isAbsolute(asset)) return asset
|
||||
return fileURLToPath(new URL(asset, import.meta.url))
|
||||
}
|
||||
|
||||
const load = (() => {
|
||||
let loading: ReturnType<typeof initialize> | undefined
|
||||
return () => (loading ??= initialize())
|
||||
})()
|
||||
|
||||
async function initialize() {
|
||||
const { Parser, Language } = await import("web-tree-sitter")
|
||||
await Parser.init({ locateFile: () => resolve(shellParserWasm.runtime) })
|
||||
const [bashLanguage, psLanguage] = await Promise.all([
|
||||
Language.load(resolve(shellParserWasm.bash)),
|
||||
Language.load(resolve(shellParserWasm.powershell)),
|
||||
])
|
||||
const bash = new Parser()
|
||||
bash.setLanguage(bashLanguage)
|
||||
const ps = new Parser()
|
||||
ps.setLanguage(psLanguage)
|
||||
return { bash, ps }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// @ts-ignore Bun embeds static file imports when compiling the CLI.
|
||||
import runtime from "web-tree-sitter/tree-sitter.wasm" with { type: "file" }
|
||||
// @ts-ignore Bun embeds static file imports when compiling the CLI.
|
||||
import bash from "tree-sitter-bash/tree-sitter-bash.wasm" with { type: "file" }
|
||||
// @ts-ignore Bun embeds static file imports when compiling the CLI.
|
||||
import powershell from "tree-sitter-powershell/tree-sitter-powershell.wasm" with { type: "file" }
|
||||
|
||||
export const shellParserWasm = { runtime, bash, powershell }
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createRequire } from "node:module"
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
export const shellParserWasm = {
|
||||
runtime: process.env.OPENCODE_TREE_SITTER_WASM_PATH ?? require.resolve("web-tree-sitter/tree-sitter.wasm"),
|
||||
bash:
|
||||
process.env.OPENCODE_TREE_SITTER_BASH_WASM_PATH ?? require.resolve("tree-sitter-bash/tree-sitter-bash.wasm"),
|
||||
powershell:
|
||||
process.env.OPENCODE_TREE_SITTER_POWERSHELL_WASM_PATH ??
|
||||
require.resolve("tree-sitter-powershell/tree-sitter-powershell.wasm"),
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export * as ShellTool from "./shell"
|
||||
|
||||
import path from "path"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import type { Content } from "@opencode-ai/schema/tool"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
@@ -11,6 +12,7 @@ import { PluginRuntime } from "../../plugin/runtime"
|
||||
import { NonNegativeInt } from "../../schema"
|
||||
import { SessionSchema } from "../../session/schema"
|
||||
import { Shell } from "../../shell"
|
||||
import { ShellParse } from "../../shell/parse"
|
||||
|
||||
export const name = "shell"
|
||||
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
|
||||
@@ -75,18 +77,6 @@ const modelOutput = (output: Output): string | undefined => {
|
||||
return `Command exited with code ${output.exit}.`
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal core shell boundary. Keep parity debt visible without pulling the
|
||||
* legacy shell runtime into core.
|
||||
*/
|
||||
// TODO: Port tree-sitter bash / PowerShell parser-based approval reduction.
|
||||
// TODO: Port BashArity reusable command-prefix approvals.
|
||||
// TODO: Add plugin shell.env environment augmentation once plugin hooks exist.
|
||||
// TODO: Persist job status and define restart recovery before exposing remote observation.
|
||||
// TODO: Add HTTP job observation only after durable status, restart recovery, and authorization are defined.
|
||||
// TODO: Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it.
|
||||
// TODO: Revisit binary output handling if stdout/stderr decoding is text-only.
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.shell",
|
||||
effect: Effect.fn("ShellTool.Plugin")(function* (ctx: PluginContext) {
|
||||
@@ -158,24 +148,34 @@ export const Plugin = {
|
||||
(invocation) =>
|
||||
Effect.gen(function* () {
|
||||
const target = yield* mutation.resolve({ path: invocation.cwd, kind: "directory" })
|
||||
const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.canonical)
|
||||
const directories = yield* Effect.forEach(parsed.directories, (directory) =>
|
||||
mutation.resolve({ path: path.resolve(target.canonical, directory), kind: "directory" }),
|
||||
)
|
||||
invocation.cwd = target.canonical
|
||||
finalTimeout = invocation.timeout
|
||||
const external = target.externalDirectory
|
||||
if (external)
|
||||
const external = [target, ...directories]
|
||||
.map((item) => item.externalDirectory)
|
||||
.filter((item) => item !== undefined)
|
||||
.filter((item, index, items) => items.findIndex((other) => other.resource === item.resource) === index)
|
||||
if (external.length > 0)
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
action: "external_directory",
|
||||
resources: external.map((item) => item.resource),
|
||||
save: external.map((item) => item.save),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
if (parsed.commands.length > 0)
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: parsed.commands.map((command) => command.resource),
|
||||
save: parsed.commands.map((command) => command.save),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [invocation.command],
|
||||
save: [invocation.command],
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
if ((yield* fsUtil.stat(target.canonical)).type !== "Directory")
|
||||
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`))
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { ShellParse } from "@opencode-ai/core/shell/parse"
|
||||
|
||||
describe("ShellParse", () => {
|
||||
test("splits bash commands and derives reusable prefixes", async () => {
|
||||
const result = await Effect.runPromise(
|
||||
ShellParse.scan("git status && npm run test -- --watch", "/bin/bash", "/workspace"),
|
||||
)
|
||||
expect(result).toEqual({
|
||||
commands: [
|
||||
{ resource: "git status", save: "git status *" },
|
||||
{ resource: "npm run test -- --watch", save: "npm run test *" },
|
||||
],
|
||||
directories: [],
|
||||
})
|
||||
})
|
||||
|
||||
test("splits PowerShell commands case-insensitively", async () => {
|
||||
const result = await Effect.runPromise(
|
||||
ShellParse.scan("Get-ChildItem; Write-Output 'done'", "C:\\Program Files\\PowerShell\\7\\pwsh.exe", "C:\\workspace"),
|
||||
)
|
||||
expect(result.commands).toEqual([
|
||||
{ resource: "Get-ChildItem", save: "Get-ChildItem *" },
|
||||
{ resource: "Write-Output 'done'", save: "Write-Output *" },
|
||||
])
|
||||
})
|
||||
|
||||
test("does not permission directory changes separately", async () => {
|
||||
const result = await Effect.runPromise(ShellParse.scan("cd 'src dir' && git status", "/bin/bash", "/workspace"))
|
||||
expect(result).toEqual({
|
||||
commands: [{ resource: "git status", save: "git status *" }],
|
||||
directories: ["src dir"],
|
||||
})
|
||||
})
|
||||
|
||||
test("extracts PowerShell directory parameters", async () => {
|
||||
const result = await Effect.runPromise(
|
||||
ShellParse.scan("Set-Location -LiteralPath '..\\outside'; Get-ChildItem", "pwsh", "C:\\workspace"),
|
||||
)
|
||||
expect(result.directories).toEqual(["..\\outside"])
|
||||
})
|
||||
|
||||
test("expands deterministic directory variables", async () => {
|
||||
const bash = await Effect.runPromise(ShellParse.scan("cd ~/src", "/bin/bash", "/workspace"))
|
||||
expect(bash.directories).toEqual([path.join(os.homedir(), "src")])
|
||||
|
||||
const powershell = await Effect.runPromise(
|
||||
ShellParse.scan('Set-Location "$PWD/src"; Set-Location $PSHOME', "/usr/local/bin/pwsh", "/workspace"),
|
||||
)
|
||||
expect(powershell.directories).toEqual(["/workspace/src", "/usr/local/bin"])
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import fs from "fs/promises"
|
||||
import { realpathSync } from "node:fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { DateTime, Deferred, Duration, Effect, Fiber, Layer, Scope, Stream } from "effect"
|
||||
@@ -222,7 +223,10 @@ describe("ShellTool", () => {
|
||||
type: "text",
|
||||
text: expect.stringContaining("Command exited with code 0."),
|
||||
})
|
||||
expect(assertions).toMatchObject([{ sessionID, action: "shell", resources: [helloCommand] }])
|
||||
expect(assertions).toMatchObject([
|
||||
{ sessionID, action: "shell", resources: [isWindows ? "Start-Sleep -Milliseconds 100" : helloCommand] },
|
||||
])
|
||||
expect(assertions[0]?.save).toEqual([isWindows ? "Start-Sleep *" : "printf *"])
|
||||
}),
|
||||
)
|
||||
},
|
||||
@@ -253,6 +257,29 @@ describe("ShellTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("permissions compound commands separately", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
return withSession(tmp.path, (registry) =>
|
||||
executeTool(registry, call({ command: "printf one && printf two" }, "call-compound")),
|
||||
).pipe(
|
||||
Effect.andThen(
|
||||
Effect.sync(() => {
|
||||
expect(assertions).toHaveLength(1)
|
||||
expect(assertions[0]).toMatchObject({
|
||||
resources: ["printf one", "printf two"],
|
||||
save: ["printf *", "printf *"],
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("captures stderr-only and mixed stdout/stderr output", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
@@ -325,6 +352,51 @@ describe("ShellTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("approves an external directory used by a directory-change command", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||
([active, outside]) => {
|
||||
reset()
|
||||
const command = isWindows
|
||||
? `Set-Location -LiteralPath '${outside.path}'; (Get-Location).Path`
|
||||
: `cd '${outside.path}' && pwd`
|
||||
return withSession(active.path, (registry) => executeTool(registry, call({ command }, "call-external-cd"))).pipe(
|
||||
Effect.andThen(
|
||||
Effect.sync(() => {
|
||||
expect(assertions.map((item) => item.action)).toEqual(["external_directory", "shell"])
|
||||
expect(assertions[0]).toMatchObject({
|
||||
resources: [path.join(realpathSync(outside.path), "*").replaceAll("\\", "/")],
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
([active, outside]) =>
|
||||
Effect.promise(() =>
|
||||
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("approves an expanded external home directory", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
const command = isWindows ? "Set-Location $HOME; (Get-Location).Path" : "cd ~ && pwd"
|
||||
return withSession(tmp.path, (registry) => executeTool(registry, call({ command }, "call-external-home"))).pipe(
|
||||
Effect.andThen(
|
||||
Effect.sync(() => {
|
||||
expect(assertions.map((item) => item.action)).toEqual(["external_directory", "shell"])
|
||||
expect(assertions[0]?.resources[0]).toStartWith(os.homedir().replaceAll("\\", "/"))
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("does not execute after external-directory or shell denial", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||
@@ -466,7 +538,7 @@ describe("ShellTool", () => {
|
||||
(tmp) => {
|
||||
reset()
|
||||
return withSession(tmp.path, (registry) =>
|
||||
executeTool(registry, call({ command: timeoutOutputCommand, timeout: 50 })),
|
||||
executeTool(registry, call({ command: timeoutOutputCommand, timeout: isWindows ? 500 : 50 })),
|
||||
).pipe(
|
||||
Effect.andThen((settled) =>
|
||||
Effect.sync(() => {
|
||||
|
||||
Reference in New Issue
Block a user