Compare commits

..
Author SHA1 Message Date
Simon Klee b89ec4b11b opencode: lazily compose default layers
defaultLayer is currently composed at import time. This makes module evaluation
sensitive to transitive import order, even though the layer graph itself is
unchanged.

Wrap these compositions in Layer.suspend() so the same graph is built on
demand instead of during module load. This reduces init-order coupling and
makes import reordering for startup work safer.
2026-04-12 11:40:35 +02:00
133 changed files with 69997 additions and 6913 deletions
-1
View File
@@ -25,7 +25,6 @@ kommander
-opencodeengineer bot that spams issues -opencodeengineer bot that spams issues
r44vc0rp r44vc0rp
rekram1-node rekram1-node
-ricardo-m-l
-robinmordasiewicz -robinmordasiewicz
simonklee simonklee
-spider-yamet clawdbot/llm psychosis, spam pinging the team -spider-yamet clawdbot/llm psychosis, spam pinging the team
+5 -3
View File
@@ -114,7 +114,7 @@ jobs:
- build-cli - build-cli
- version - version
runs-on: blacksmith-4vcpu-windows-2025 runs-on: blacksmith-4vcpu-windows-2025
if: github.repository == 'anomalyco/opencode' if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
env: env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
@@ -213,6 +213,7 @@ jobs:
needs: needs:
- build-cli - build-cli
- version - version
if: github.ref_name != 'beta'
continue-on-error: false continue-on-error: false
env: env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
@@ -389,7 +390,7 @@ jobs:
needs: needs:
- build-cli - build-cli
- version - version
if: github.repository == 'anomalyco/opencode' if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
continue-on-error: false continue-on-error: false
env: env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
@@ -590,12 +591,13 @@ jobs:
path: packages/opencode/dist path: packages/opencode/dist
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v4
if: github.ref_name != 'beta'
with: with:
name: opencode-cli-signed-windows name: opencode-cli-signed-windows
path: packages/opencode/dist path: packages/opencode/dist
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v4
if: needs.version.outputs.release if: needs.version.outputs.release && github.ref_name != 'beta'
with: with:
pattern: latest-yml-* pattern: latest-yml-*
path: /tmp/latest-yml path: /tmp/latest-yml
-5
View File
@@ -319,7 +319,6 @@
"@actions/core": "1.11.1", "@actions/core": "1.11.1",
"@actions/github": "6.0.1", "@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.16.1", "@agentclientprotocol/sdk": "0.16.1",
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.93", "@ai-sdk/amazon-bedrock": "4.0.93",
"@ai-sdk/anthropic": "3.0.67", "@ai-sdk/anthropic": "3.0.67",
"@ai-sdk/azure": "3.0.49", "@ai-sdk/azure": "3.0.49",
@@ -708,8 +707,6 @@
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.16.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw=="], "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.16.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw=="],
"@ai-sdk/alibaba": ["@ai-sdk/alibaba@1.0.17", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZbE+U5bWz2JBc5DERLowx5+TKbjGBE93LqKZAWvuEn7HOSQMraxFMZuc0ST335QZJAyfBOzh7m1mPQ+y7EaaoA=="],
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.93", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.69", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hcXDU8QDwpAzLVTuY932TQVlIij9+iaVTxc5mPGY6yb//JMAAC5hMVhg93IrxlrxWLvMgjezNgoZGwquR+SGnw=="], "@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.93", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.69", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hcXDU8QDwpAzLVTuY932TQVlIij9+iaVTxc5mPGY6yb//JMAAC5hMVhg93IrxlrxWLvMgjezNgoZGwquR+SGnw=="],
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rwLi/Rsuj2pYniQXIrvClHvXDzgM4UQHHnvHTWEF14efnlKclG/1ghpNC+adsRujAbCTr6gRsSbDE2vEqriV7g=="], "@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rwLi/Rsuj2pYniQXIrvClHvXDzgM4UQHHnvHTWEF14efnlKclG/1ghpNC+adsRujAbCTr6gRsSbDE2vEqriV7g=="],
@@ -5006,8 +5003,6 @@
"@actions/http-client/undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="], "@actions/http-client/undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="],
"@ai-sdk/alibaba/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.69", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LshR7X3pFugY0o41G2VKTmg1XoGpSl7uoYWfzk6zjVZLhCfeFiwgpOga+eTV4XY1VVpZwKVqRnkDbIL7K2eH5g=="], "@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.69", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LshR7X3pFugY0o41G2VKTmg1XoGpSl7uoYWfzk6zjVZLhCfeFiwgpOga+eTV4XY1VVpZwKVqRnkDbIL7K2eH5g=="],
"@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.12", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA=="], "@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.12", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-g29OM3dy+sZ3ioTs8zjQOK1N+KnNr9ptP9xtdPcdr64=", "x86_64-linux": "sha256-fNRQYkucjXr1D61HJRScJpDa6+oBdyhgTBxCu+PE2kQ=",
"aarch64-linux": "sha256-Iu91KwDcV5omkf4Ngny1aYpyCkPLjuoWOVUDOJUhW1k=", "aarch64-linux": "sha256-V8J6kn2nSdXrplyqi6aIqNlHcVjSxvye+yC/YFO7PF4=",
"aarch64-darwin": "sha256-bk3G6m+Yo60Ea3Kyglc37QZf5Vm7MLMFcxemjc7HnL0=", "aarch64-darwin": "sha256-6cLmUJVUycGALCmslXuloVGBSlFOSHRjsWjx7KOW8rg=",
"x86_64-darwin": "sha256-y3hooQw13Z3Cu0KFfXYdpkTEeKTyuKd+a/jsXHQLdqA=" "x86_64-darwin": "sha256-kcOSO3NFIJh79ylLotG41ovWLQfH5kh1WYFghUu+4HE="
} }
} }
@@ -274,7 +274,7 @@ const WorkspaceSessionList = (props: {
<div class="relative w-full py-1"> <div class="relative w-full py-1">
<Button <Button
variant="ghost" variant="ghost"
class="flex w-full text-left justify-start text-14-regular text-text-weak pl-2 pr-10" class="flex w-full text-left justify-start text-14-regular text-text-weak pl-9 pr-10"
size="large" size="large"
onClick={(e: MouseEvent) => { onClick={(e: MouseEvent) => {
props.loadMore() props.loadMore()
@@ -642,10 +642,10 @@ export function MessageTimeline(props: {
onClick={props.onResumeScroll} onClick={props.onResumeScroll}
> >
<div <div
class="flex items-center justify-center w-8 h-6 rounded-[6px] border border-border-weaker-base bg-[color-mix(in_srgb,var(--surface-raised-stronger-non-alpha)_80%,transparent)] backdrop-blur-[0.75px] transition-colors group-hover:border-[var(--border-weak-base)] group-hover:[--icon-base:var(--icon-hover)]" class="flex items-center justify-center w-8 h-6 rounded-[6px] border border-[var(--gray-dark-7)] bg-[color-mix(in_srgb,var(--gray-dark-3)_80%,transparent)] backdrop-blur-[0.75px] transition-colors group-hover:border-[var(--gray-dark-8)] [--icon-base:var(--gray-dark-10)] group-hover:[--icon-base:var(--gray-dark-11)]"
style={{ style={{
"box-shadow": "box-shadow":
"0 51px 60px 0 rgba(0,0,0,0.10), 0 15px 18px 0 rgba(0,0,0,0.12), 0 6.386px 7.513px 0 rgba(0,0,0,0.12), 0 2.31px 2.717px 0 rgba(0,0,0,0.20)", "0 51px 60px 0 rgba(0,0,0,0.13), 0 15.375px 18.088px 0 rgba(0,0,0,0.19), 0 6.386px 7.513px 0 rgba(0,0,0,0.25), 0 2.31px 2.717px 0 rgba(0,0,0,0.38)",
}} }}
> >
<Icon name="arrow-down-to-line" size="small" /> <Icon name="arrow-down-to-line" size="small" />
@@ -66,7 +66,7 @@ export function createMainWindow(globals: Globals) {
y: state.y, y: state.y,
width: state.width, width: state.width,
height: state.height, height: state.height,
show: false, show: true,
title: "OpenCode", title: "OpenCode",
icon: iconPath(), icon: iconPath(),
backgroundColor, backgroundColor,
@@ -94,10 +94,6 @@ export function createMainWindow(globals: Globals) {
wireZoom(win) wireZoom(win)
injectGlobals(win, globals) injectGlobals(win, globals)
win.once("ready-to-show", () => {
win.show()
})
return win return win
} }
+2 -2
View File
@@ -13,7 +13,7 @@
Use these rules when writing or migrating Effect code. Use these rules when writing or migrating Effect code.
See `specs/effect/migration.md` for the compact pattern reference and examples. See `specs/effect-migration.md` for the compact pattern reference and examples.
## Core ## Core
@@ -51,7 +51,7 @@ See `specs/effect/migration.md` for the compact pattern reference and examples.
## Effect.cached for deduplication ## Effect.cached for deduplication
Use `Effect.cached` when multiple concurrent callers should share a single in-flight computation rather than storing `Fiber | undefined` or `Promise | undefined` manually. See `specs/effect/migration.md` for the full pattern. Use `Effect.cached` when multiple concurrent callers should share a single in-flight computation rather than storing `Fiber | undefined` or `Promise | undefined` manually. See `specs/effect-migration.md` for the full pattern.
## Instance.bind — ALS for native callbacks ## Instance.bind — ALS for native callbacks
@@ -1,16 +0,0 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_workspace` (
`id` text PRIMARY KEY,
`type` text NOT NULL,
`name` text DEFAULT '' NOT NULL,
`branch` text,
`directory` text,
`extra` text,
`project_id` text NOT NULL,
CONSTRAINT `fk_workspace_project_id_project_id_fk` FOREIGN KEY (`project_id`) REFERENCES `project`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
INSERT INTO `__new_workspace`(`id`, `type`, `branch`, `name`, `directory`, `extra`, `project_id`) SELECT `id`, `type`, `branch`, `name`, `directory`, `extra`, `project_id` FROM `workspace`;--> statement-breakpoint
DROP TABLE `workspace`;--> statement-breakpoint
ALTER TABLE `__new_workspace` RENAME TO `workspace`;--> statement-breakpoint
PRAGMA foreign_keys=ON;
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -14,11 +14,18 @@
"fix-node-pty": "bun run script/fix-node-pty.ts", "fix-node-pty": "bun run script/fix-node-pty.ts",
"upgrade-opentui": "bun run script/upgrade-opentui.ts", "upgrade-opentui": "bun run script/upgrade-opentui.ts",
"dev": "bun run --conditions=browser ./src/index.ts", "dev": "bun run --conditions=browser ./src/index.ts",
"random": "echo 'Random script updated at $(date)' && echo 'Change queued successfully' && echo 'Another change made' && echo 'Yet another change' && echo 'One more change' && echo 'Final change' && echo 'Another final change' && echo 'Yet another final change'",
"clean": "echo 'Cleaning up...' && rm -rf node_modules dist",
"lint": "echo 'Running lint checks...' && bun test --coverage",
"format": "echo 'Formatting code...' && bun run --prettier --write src/**/*.ts",
"docs": "echo 'Generating documentation...' && find src -name '*.ts' -exec echo 'Processing: {}' \\;",
"deploy": "echo 'Deploying application...' && bun run build && echo 'Deployment completed successfully'",
"db": "bun drizzle-kit" "db": "bun drizzle-kit"
}, },
"bin": { "bin": {
"opencode": "./bin/opencode" "opencode": "./bin/opencode"
}, },
"randomField": "this-is-a-random-value-12345",
"exports": { "exports": {
"./*": "./src/*.ts" "./*": "./src/*.ts"
}, },
@@ -76,7 +83,6 @@
"@actions/core": "1.11.1", "@actions/core": "1.11.1",
"@actions/github": "6.0.1", "@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.16.1", "@agentclientprotocol/sdk": "0.16.1",
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.93", "@ai-sdk/amazon-bedrock": "4.0.93",
"@ai-sdk/anthropic": "3.0.67", "@ai-sdk/anthropic": "3.0.67",
"@ai-sdk/azure": "3.0.49", "@ai-sdk/azure": "3.0.49",
+3 -12
View File
@@ -1,5 +1,3 @@
import { AppRuntime } from "@/effect/app-runtime"
const dir = process.env.OPENCODE_E2E_PROJECT_DIR ?? process.cwd() const dir = process.env.OPENCODE_E2E_PROJECT_DIR ?? process.cwd()
const title = process.env.OPENCODE_E2E_SESSION_TITLE ?? "E2E Session" const title = process.env.OPENCODE_E2E_SESSION_TITLE ?? "E2E Session"
const text = process.env.OPENCODE_E2E_MESSAGE ?? "Seeded for UI e2e" const text = process.env.OPENCODE_E2E_MESSAGE ?? "Seeded for UI e2e"
@@ -18,20 +16,14 @@ const seed = async () => {
const { Project } = await import("../src/project/project") const { Project } = await import("../src/project/project")
const { ModelID, ProviderID } = await import("../src/provider/schema") const { ModelID, ProviderID } = await import("../src/provider/schema")
const { ToolRegistry } = await import("../src/tool/registry") const { ToolRegistry } = await import("../src/tool/registry")
const { Effect } = await import("effect")
try { try {
await Instance.provide({ await Instance.provide({
directory: dir, directory: dir,
init: () => AppRuntime.runPromise(InstanceBootstrap), init: InstanceBootstrap,
fn: async () => { fn: async () => {
await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.waitForDependencies())) await Config.waitForDependencies()
await AppRuntime.runPromise( await ToolRegistry.ids()
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
yield* registry.ids()
}),
)
const session = await Session.create({ title }) const session = await Session.create({ title })
const messageID = MessageID.ascending() const messageID = MessageID.ascending()
@@ -62,7 +54,6 @@ const seed = async () => {
}) })
} finally { } finally {
await Instance.disposeAll().catch(() => {}) await Instance.disposeAll().catch(() => {})
await AppRuntime.dispose().catch(() => {})
} }
} }
@@ -178,9 +178,7 @@ That is fine for leaf files like `schema.ts`. Keep the service surface in the ow
## Migration checklist ## Migration checklist
Service-shape migrated (single namespace, traced methods, `InstanceState` where needed). Fully migrated (single namespace, InstanceState where needed, flattened facade):
This checklist is only about the service shape migration. Many of these services still keep `makeRuntime(...)` plus async facade exports; that facade-removal phase is tracked separately in [Destroying the facades](#destroying-the-facades).
- [x] `Account``account/index.ts` - [x] `Account``account/index.ts`
- [x] `Agent``agent/agent.ts` - [x] `Agent``agent/agent.ts`
@@ -223,16 +221,59 @@ This checklist is only about the service shape migration. Many of these services
- [x] `Provider``provider/provider.ts` - [x] `Provider``provider/provider.ts`
- [x] `Storage``storage/storage.ts` - [x] `Storage``storage/storage.ts`
- [x] `ShareNext``share/share-next.ts` - [x] `ShareNext``share/share-next.ts`
Still open:
- [x] `SessionTodo``session/todo.ts` - [x] `SessionTodo``session/todo.ts`
- [ ] `SyncEvent``sync/index.ts`
- [ ] `Workspace``control-plane/workspace.ts`
Still open at the service-shape level: ## Tool interface → Effect
- [ ] `SyncEvent``sync/index.ts` (deferred pending sync with James) `Tool.Def.execute` and `Tool.Info.init` already return `Effect` on this branch. Tool definitions should now stay Effect-native all the way through initialization instead of using Promise-returning init callbacks. Tools can still use lazy init callbacks when they need instance-bound state at init time, but those callbacks should return `Effect`, not `Promise`. Remaining work is:
- [ ] `Workspace``control-plane/workspace.ts` (deferred pending sync with James)
## Tool migration 1. Migrate each tool body to return Effects
2. Keep `Tool.define()` inputs Effect-native
3. Update remaining callers to `yield*` tool initialization instead of `await`ing
Tool-specific migration guidance and checklist live in `tools.md`. ### Tool migration details
With `Tool.Info.init()` now effectful, use this transitional pattern for migrated tools that still need Promise-based boundaries internally:
- `Tool.defineEffect(...)` should `yield*` the services the tool depends on and close over them in the returned tool definition.
- Keep the bridge at the Promise boundary only inside the tool body when required by external APIs. Do not return Promise-based init callbacks from `Tool.define()`.
- If a tool starts requiring new services, wire them into `ToolRegistry.defaultLayer` so production callers resolve the same dependencies as tests.
Tool tests should use the existing Effect helpers in `packages/opencode/test/lib/effect.ts`:
- Use `testEffect(...)` / `it.live(...)` instead of creating fake local wrappers around effectful tools.
- Yield the real tool export, then initialize it: `const info = yield* ReadTool`, `const tool = yield* info.init()`.
- Run tests inside a real instance with `provideTmpdirInstance(...)` or `provideInstance(tmpdirScoped(...))` so instance-scoped services resolve exactly as they do in production.
This keeps migrated tool tests aligned with the production service graph today, and makes the eventual `Tool.Info``Effect` cleanup mostly mechanical later.
Individual tools, ordered by value:
- [ ] `apply_patch.ts` — HIGH: multi-step orchestration, error accumulation, Bus events
- [ ] `bash.ts` — HIGH: shell orchestration, quoting, timeout handling, output capture
- [x] `read.ts` — HIGH: streaming I/O, readline, binary detection → FileSystem + Stream
- [ ] `edit.ts` — HIGH: multi-step diff/format/publish pipeline, FileWatcher lock
- [ ] `grep.ts` — MEDIUM: spawns ripgrep → ChildProcessSpawner, timeout handling
- [ ] `write.ts` — MEDIUM: permission checks, diagnostics polling, Bus events
- [ ] `codesearch.ts` — MEDIUM: HTTP + SSE + manual timeout → HttpClient + Effect.timeout
- [ ] `webfetch.ts` — MEDIUM: fetch with UA retry, size limits → HttpClient
- [ ] `websearch.ts` — MEDIUM: MCP over HTTP → HttpClient
- [ ] `batch.ts` — MEDIUM: parallel execution, per-call error recovery → Effect.all
- [ ] `task.ts` — MEDIUM: task state management
- [ ] `ls.ts` — MEDIUM: bounded directory listing over ripgrep-backed traversal
- [ ] `multiedit.ts` — MEDIUM: sequential edit orchestration over `edit.ts`
- [ ] `glob.ts` — LOW: simple async generator
- [ ] `lsp.ts` — LOW: dispatch switch over LSP operations
- [ ] `question.ts` — LOW: prompt wrapper
- [ ] `skill.ts` — LOW: skill tool adapter
- [ ] `todo.ts` — LOW: todo persistence wrapper
- [ ] `invalid.ts` — LOW: invalid-tool fallback
- [ ] `plan.ts` — LOW: plan file operations
## Effect service adoption in already-migrated code ## Effect service adoption in already-migrated code
@@ -240,19 +281,27 @@ Some already-effectified areas still use raw `Filesystem.*` or `Process.spawn` i
### `Filesystem.*``AppFileSystem.Service` (yield in layer) ### `Filesystem.*``AppFileSystem.Service` (yield in layer)
- [x] `config/config.ts``installDependencies()` now uses `AppFileSystem` - [ ] `file/index.ts` — 1 remaining `Filesystem.readText()` call in untracked diff handling
- [x] `provider/provider.ts` — recent model state now reads via `AppFileSystem.Service` - [ ] `config/config.ts` 5 remaining `Filesystem.*` calls in `installDependencies()`
- [ ] `provider/provider.ts` — 1 remaining `Filesystem.readJson()` call for recent model state
### `Process.spawn``ChildProcessSpawner` (yield in layer) ### `Process.spawn``ChildProcessSpawner` (yield in layer)
- [x] `format/formatter.ts`direct `Process.spawn()` checks removed (`air`, `uv`) - [ ] `format/formatter.ts`2 remaining `Process.spawn()` checks (`air`, `uv`)
- [ ] `lsp/server.ts` — multiple `Process.spawn()` installs/download helpers - [ ] `lsp/server.ts` — multiple `Process.spawn()` installs/download helpers
## Filesystem consolidation ## Filesystem consolidation
`util/filesystem.ts` is still used widely across `src/`, and raw `fs` / `fs/promises` imports still exist in multiple tooling and infrastructure files. As services and tools are effectified, they should switch from `Filesystem.*` to yielding `AppFileSystem.Service` where possible — this should happen naturally during each migration, not as a separate sweep. `util/filesystem.ts` (raw fs wrapper) is currently imported by **34 files**. The effectified `AppFileSystem` service (`filesystem/index.ts`) is currently imported by **15 files**. As services and tools are effectified, they should switch from `Filesystem.*` to yielding `AppFileSystem.Service` — this happens naturally during each migration, not as a separate effort.
Tool-specific filesystem cleanup notes live in `tools.md`. Similarly, **21 files** still import raw `fs` or `fs/promises` directly. These should migrate to `AppFileSystem` or `Filesystem.*` as they're touched.
Current raw fs users that will convert during tool migration:
- `tool/read.ts` — fs.createReadStream, readline
- `tool/apply_patch.ts` — fs/promises
- `file/ripgrep.ts` — fs/promises
- `patch/index.ts` — fs, fs/promises
## Primitives & utilities ## Primitives & utilities
@@ -263,9 +312,7 @@ Tool-specific filesystem cleanup notes live in `tools.md`.
## Destroying the facades ## Destroying the facades
This phase is still broadly open. As of 2026-04-11 there are still 31 `makeRuntime(...)` call sites under `src/`, and many service namespaces still export async facade helpers like `export async function read(...) { return runPromise(...) }`. Every service currently exports async facade functions at the bottom of its namespace — `export async function read(...) { return runPromise(...) }` — backed by a per-service `makeRuntime`. These exist because cyclic imports used to force each service to build its own independent runtime. Now that the layer DAG is acyclic and `AppRuntime` (`src/effect/app-runtime.ts`) composes everything into one `ManagedRuntime`, we're removing them.
These facades exist because cyclic imports used to force each service to build its own independent runtime. Now that the layer DAG is acyclic and `AppRuntime` (`src/effect/app-runtime.ts`) composes everything into one `ManagedRuntime`, we're removing them.
### Process ### Process
@@ -294,14 +341,47 @@ For each service, the migration is roughly:
- `ShareNext` — migrated 2026-04-11. Swapped remaining async callers to `AppRuntime.runPromise(ShareNext.Service.use(...))`, removed the `makeRuntime(...)` facade, and kept instance bootstrap on the shared app runtime. - `ShareNext` — migrated 2026-04-11. Swapped remaining async callers to `AppRuntime.runPromise(ShareNext.Service.use(...))`, removed the `makeRuntime(...)` facade, and kept instance bootstrap on the shared app runtime.
- `SessionTodo` — migrated 2026-04-10. Already matched the target service shape in `session/todo.ts`: single namespace, traced Effect methods, and no `makeRuntime(...)` facade remained; checklist updated to reflect the completed migration. - `SessionTodo` — migrated 2026-04-10. Already matched the target service shape in `session/todo.ts`: single namespace, traced Effect methods, and no `makeRuntime(...)` facade remained; checklist updated to reflect the completed migration.
- `Storage` — migrated 2026-04-10. One production caller (`Session.diff`) and all storage.test.ts tests converted to effectful style. Facades and `makeRuntime` removed. - `Storage` — migrated 2026-04-10. One production caller (`Session.diff`) and all storage.test.ts tests converted to effectful style. Facades and `makeRuntime` removed.
- `SessionRunState` — migrated 2026-04-11. Single caller in `server/instance/session.ts` converted; facade removed. - `SessionRunState` — migrated 2026-04-11. Single caller in `server/routes/session.ts` converted; facade removed.
- `Account` — migrated 2026-04-11. Callers in `server/instance/experimental.ts` and `cli/cmd/account.ts` converted; facade removed. - `Account` — migrated 2026-04-11. Callers in `server/routes/experimental.ts` and `cli/cmd/account.ts` converted; facade removed.
- `Instruction` — migrated 2026-04-11. Test-only callers converted; facade removed. - `Instruction` — migrated 2026-04-11. Test-only callers converted; facade removed.
- `FileTime` — migrated 2026-04-11. Test-only callers converted; facade removed. - `FileTime` — migrated 2026-04-11. Test-only callers converted; facade removed.
- `FileWatcher` — migrated 2026-04-11. Callers in `project/bootstrap.ts` and test converted; facade removed. - `FileWatcher` — migrated 2026-04-11. Callers in `project/bootstrap.ts` and test converted; facade removed.
- `Question` — migrated 2026-04-11. Callers in `server/instance/question.ts` and test converted; facade removed. - `Question` — migrated 2026-04-11. Callers in `server/routes/question.ts` and test converted; facade removed.
- `Truncate` — migrated 2026-04-11. Caller in `tool/tool.ts` and test converted; facade removed. - `Truncate` — migrated 2026-04-11. Caller in `tool/tool.ts` and test converted; facade removed.
## Route handler effectification ## Route handler effectification
Route-handler migration guidance and checklist live in `routes.md`. Route handlers should wrap their entire body in a single `AppRuntime.runPromise(Effect.gen(...))` call, yielding services from context rather than calling facades one-by-one. This eliminates multiple `runPromise` round-trips and lets handlers compose naturally.
```ts
// Before — one facade call per service
;async (c) => {
await SessionRunState.assertNotBusy(id)
await Session.removeMessage({ sessionID: id, messageID })
return c.json(true)
}
// After — one Effect.gen, yield services from context
;async (c) => {
await AppRuntime.runPromise(
Effect.gen(function* () {
const state = yield* SessionRunState.Service
const session = yield* Session.Service
yield* state.assertNotBusy(id)
yield* session.removeMessage({ sessionID: id, messageID })
}),
)
return c.json(true)
}
```
When migrating, always use `{ concurrency: "unbounded" }` with `Effect.all` — route handlers should run independent service calls in parallel, not sequentially.
Route files to convert (each handler that calls facades should be wrapped):
- [ ] `server/routes/session.ts` — heaviest; uses Session, SessionPrompt, SessionRevert, SessionCompaction, SessionShare, SessionSummary, SessionRunState, Agent, Permission, Bus
- [ ] `server/routes/global.ts` — uses Config, Project, Provider, Vcs, Snapshot, Agent
- [ ] `server/routes/provider.ts` — uses Provider, Auth, Config
- [ ] `server/routes/question.ts` — uses Question
- [ ] `server/routes/pty.ts` — uses Pty
- [ ] `server/routes/experimental.ts` — uses Account, ToolRegistry, Agent, MCP, Config
-137
View File
@@ -1,137 +0,0 @@
# HttpApi migration
Practical notes for an eventual migration of `packages/opencode` server routes from the current Hono handlers to Effect `HttpApi`, either as a full replacement or as a parallel surface.
## Goal
Use Effect `HttpApi` where it gives us a better typed contract for:
- route definition
- request decoding and validation
- typed success and error responses
- OpenAPI generation
- handler composition inside Effect
This should be treated as a later-stage HTTP boundary migration, not a prerequisite for ongoing service, route-handler, or schema work.
## Core model
`HttpApi` is definition-first.
- `HttpApi` is the root API
- `HttpApiGroup` groups related endpoints
- `HttpApiEndpoint` defines a single route and its request / response schemas
- handlers are implemented separately from the contract
This is a better fit once route inputs and outputs are already moving toward Effect Schema-first models.
## Why it is relevant here
The current route-effectification work is already pushing handlers toward:
- one `AppRuntime.runPromise(Effect.gen(...))` body
- yielding services from context
- using typed Effect errors instead of Promise wrappers
That work is a good prerequisite for `HttpApi`. Once the handler body is already a composed Effect, the remaining migration is mostly about replacing the Hono route declaration and validator layer.
## What HttpApi gives us
### Contracts
Request params, query, payload, success payloads, and typed error payloads are declared in one place using Effect Schema.
### Validation and decoding
Incoming data is decoded through Effect Schema instead of hand-maintained Zod validators per route.
### OpenAPI
`HttpApi` can derive OpenAPI from the API definition, which overlaps with the current `describeRoute(...)` and `resolver(...)` pattern.
### Typed errors
`Schema.TaggedErrorClass` maps naturally to endpoint error contracts.
## Likely fit for opencode
Best fit first:
- JSON request / response endpoints
- route groups that already mostly delegate into services
- endpoints whose request and response models can be defined with Effect Schema
Harder / later fit:
- SSE endpoints
- websocket endpoints
- streaming handlers
- routes with heavy Hono-specific middleware assumptions
## Current blockers and gaps
### Schema split
Many route boundaries still use Zod-first validators. That does not block all experimentation, but full `HttpApi` adoption is easier after the domain and boundary types are more consistently Schema-first with `.zod` compatibility only where needed.
### Mixed handler styles
Many current `server/instance/*.ts` handlers still call async facades directly. Migrating those to composed `Effect.gen(...)` handlers is the low-risk step to do first.
### Non-JSON routes
The server currently includes SSE, websocket, and streaming-style endpoints. Those should not be the first `HttpApi` targets.
### Existing Hono integration
The current server composition, middleware, and docs flow are Hono-centered today. That suggests a parallel or incremental adoption plan is safer than a flag day rewrite.
## Recommended strategy
### 1. Finish the prerequisites first
- continue route-handler effectification in `server/instance/*.ts`
- continue schema migration toward Effect Schema-first DTOs and errors
- keep removing service facades
### 2. Start with one parallel group
Introduce one small `HttpApi` group for plain JSON endpoints only. Good initial candidates are the least stateful endpoints in:
- `server/instance/question.ts`
- `server/instance/provider.ts`
- `server/instance/permission.ts`
Avoid `session.ts`, SSE, websocket, and TUI-facing routes first.
### 3. Reuse existing services
Do not re-architect business logic during the HTTP migration. `HttpApi` handlers should call the same Effect services already used by the Hono handlers.
### 4. Run in parallel before replacing
Prefer mounting an experimental `HttpApi` surface alongside the existing Hono routes first. That lowers migration risk and lets us compare:
- handler ergonomics
- OpenAPI output
- auth and middleware integration
- test ergonomics
### 5. Migrate JSON route groups gradually
If the parallel slice works well, migrate additional JSON route groups one at a time. Leave streaming-style endpoints on Hono until there is a clear reason to move them.
## Proposed first steps
- [ ] add one small spike that defines an `HttpApi` group for a simple JSON route set
- [ ] use Effect Schema request / response types for that slice
- [ ] keep the underlying service calls identical to the current handlers
- [ ] compare generated OpenAPI against the current Hono/OpenAPI setup
- [ ] document how auth, instance lookup, and error mapping would compose in the new stack
- [ ] decide after the spike whether `HttpApi` should stay parallel, replace only some groups, or become the long-term default
## Rule of thumb
Do not start with the hardest route file.
If `HttpApi` is adopted here, it should arrive after the handler body is already Effect-native and after the relevant request / response models have moved to Effect Schema.
-66
View File
@@ -1,66 +0,0 @@
# Route handler effectification
Practical reference for converting server route handlers in `packages/opencode` to a single `AppRuntime.runPromise(Effect.gen(...))` body.
## Goal
Route handlers should wrap their entire body in a single `AppRuntime.runPromise(Effect.gen(...))` call, yielding services from context rather than calling facades one-by-one.
This eliminates multiple `runPromise` round-trips and lets handlers compose naturally.
```ts
// Before - one facade call per service
;async (c) => {
await SessionRunState.assertNotBusy(id)
await Session.removeMessage({ sessionID: id, messageID })
return c.json(true)
}
// After - one Effect.gen, yield services from context
;async (c) => {
await AppRuntime.runPromise(
Effect.gen(function* () {
const state = yield* SessionRunState.Service
const session = yield* Session.Service
yield* state.assertNotBusy(id)
yield* session.removeMessage({ sessionID: id, messageID })
}),
)
return c.json(true)
}
```
## Rules
- Wrap the whole handler body in one `AppRuntime.runPromise(Effect.gen(...))` call when the handler is service-heavy.
- Yield services from context instead of calling async facades repeatedly.
- When independent service calls can run in parallel, use `Effect.all(..., { concurrency: "unbounded" })`.
- Prefer one composed Effect body over multiple separate `runPromise(...)` calls in the same handler.
## Current route files
Current instance route files live under `src/server/instance`, not `server/routes`.
The main migration targets are:
- [ ] `server/instance/session.ts` — heaviest; still has many direct facade calls for Session, SessionPrompt, SessionRevert, SessionCompaction, SessionShare, SessionSummary, Agent, Bus
- [ ] `server/instance/global.ts` — still has direct facade calls for Config and instance lifecycle actions
- [ ] `server/instance/provider.ts` — still has direct facade calls for Config and Provider
- [ ] `server/instance/question.ts` — partially converted; still worth tracking here until it consistently uses the composed style
- [ ] `server/instance/pty.ts` — still calls Pty facades directly
- [ ] `server/instance/experimental.ts` — mixed state; some handlers are already composed, others still use facades
Additional route files that still participate in the migration:
- [ ] `server/instance/index.ts` — Vcs, Agent, Skill, LSP, Format
- [ ] `server/instance/file.ts` — Ripgrep, File, LSP
- [ ] `server/instance/mcp.ts` — MCP facade-heavy
- [ ] `server/instance/permission.ts` — Permission
- [ ] `server/instance/workspace.ts` — Workspace
- [ ] `server/instance/tui.ts` — Bus and Session
- [ ] `server/instance/middleware.ts` — Session and Workspace lookups
## Notes
- Some handlers already use `AppRuntime.runPromise(Effect.gen(...))` in isolated places. Keep pushing those files toward one consistent style.
- Route conversion is closely tied to facade removal. As services lose `makeRuntime`-backed async exports, route handlers should switch to yielding the service directly.
-99
View File
@@ -1,99 +0,0 @@
# Schema migration
Practical reference for migrating data types in `packages/opencode` from Zod-first definitions to Effect Schema with Zod compatibility shims.
## Goal
Use Effect Schema as the source of truth for domain models, IDs, inputs, outputs, and typed errors.
Keep Zod available at existing HTTP, tool, and compatibility boundaries by exposing a `.zod` field derived from the Effect schema.
## Preferred shapes
### Data objects
Use `Schema.Class` for structured data.
```ts
export class Info extends Schema.Class<Info>("Foo.Info")({
id: FooID,
name: Schema.String,
enabled: Schema.Boolean,
}) {
static readonly zod = zod(Info)
}
```
If the class cannot reference itself cleanly during initialization, use the existing two-step pattern:
```ts
const _Info = Schema.Struct({
id: FooID,
name: Schema.String,
})
export const Info = Object.assign(_Info, {
zod: zod(_Info),
})
```
### Errors
Use `Schema.TaggedErrorClass` for domain errors.
```ts
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("FooNotFoundError", {
id: FooID,
}) {}
```
### IDs and branded leaf types
Keep branded/schema-backed IDs as Effect schemas and expose `static readonly zod` for compatibility when callers still expect Zod.
## Compatibility rule
During migration, route validators, tool parameters, and any existing Zod-based boundary should consume the derived `.zod` schema instead of maintaining a second hand-written Zod schema.
The default should be:
- Effect Schema owns the type
- `.zod` exists only as a compatibility surface
- new domain models should not start Zod-first unless there is a concrete boundary-specific need
## When Zod can stay
It is fine to keep a Zod-native schema temporarily when:
- the type is only used at an HTTP or tool boundary
- the validator depends on Zod-only transforms or behavior not yet covered by `zod()`
- the migration would force unrelated churn across a large call graph
When this happens, prefer leaving a short note or TODO rather than silently creating a parallel schema source of truth.
## Ordering
Migrate in this order:
1. Shared leaf models and `schema.ts` files
2. Exported `Info`, `Input`, `Output`, and DTO types
3. Tagged domain errors
4. Service-local internal models
5. Route and tool boundary validators that can switch to `.zod`
This keeps shared types canonical first and makes boundary updates mostly mechanical.
## Checklist
- [ ] Shared `schema.ts` leaf models are Effect Schema-first
- [ ] Exported `Info` / `Input` / `Output` types use `Schema.Class` where appropriate
- [ ] Domain errors use `Schema.TaggedErrorClass`
- [ ] Migrated types expose `.zod` for back compatibility
- [ ] Route and tool validators consume derived `.zod` instead of duplicate Zod definitions
- [ ] New domain models default to Effect Schema first
## Notes
- Use `@/util/effect-zod` for all Schema -> Zod conversion.
- Prefer one canonical schema definition. Avoid maintaining parallel Zod and Effect definitions for the same domain type.
- Keep the migration incremental. Converting the domain model first is more valuable than converting every boundary in the same change.
-96
View File
@@ -1,96 +0,0 @@
# Tool migration
Practical reference for the current tool-migration state in `packages/opencode`.
## Status
`Tool.Def.execute` and `Tool.Info.init` already return `Effect` on this branch, and the built-in tool surface is now largely on the target shape.
The current exported tools in `src/tool` all use `Tool.define(...)` with Effect-based initialization, and nearly all of them already build their tool body with `Effect.gen(...)` and `Effect.fn(...)`.
So the remaining work is no longer "convert tools to Effect at all". The remaining work is mostly:
1. remove Promise and raw platform bridges inside individual tool bodies
2. swap tool internals to Effect-native services like `AppFileSystem`, `HttpClient`, and `ChildProcessSpawner`
3. keep tests and callers aligned with `yield* info.init()` and real service graphs
## Current shape
`Tool.define(...)` is already the Effect-native helper here.
- `init` is an `Effect`
- `info.init()` returns an `Effect`
- `execute(...)` returns an `Effect`
That means a tool does not need a separate `Tool.defineEffect(...)` helper to count as migrated. A tool is effectively migrated when its init and execute path stay Effect-native, even if some internals still bridge to Promise-based or raw APIs.
## Tests
Tool tests should use the existing Effect helpers in `packages/opencode/test/lib/effect.ts`:
- Use `testEffect(...)` / `it.live(...)` instead of creating fake local wrappers around effectful tools.
- Yield the real tool export, then initialize it: `const info = yield* ReadTool`, `const tool = yield* info.init()`.
- Run tests inside a real instance with `provideTmpdirInstance(...)` or `provideInstance(tmpdirScoped(...))` so instance-scoped services resolve exactly as they do in production.
This keeps tool tests aligned with the production service graph and makes follow-up cleanup mostly mechanical.
## Exported tools
These exported tool definitions already exist in `src/tool` and are on the current Effect-native `Tool.define(...)` path:
- [x] `apply_patch.ts`
- [x] `bash.ts`
- [x] `codesearch.ts`
- [x] `edit.ts`
- [x] `glob.ts`
- [x] `grep.ts`
- [x] `invalid.ts`
- [x] `ls.ts`
- [x] `lsp.ts`
- [x] `multiedit.ts`
- [x] `plan.ts`
- [x] `question.ts`
- [x] `read.ts`
- [x] `skill.ts`
- [x] `task.ts`
- [x] `todo.ts`
- [x] `webfetch.ts`
- [x] `websearch.ts`
- [x] `write.ts`
Notes:
- `batch.ts` is no longer a current tool file and should not be tracked here.
- `truncate.ts` is an Effect service used by tools, not a tool definition itself.
- `mcp-exa.ts`, `external-directory.ts`, and `schema.ts` are support modules, not standalone tool definitions.
## Follow-up cleanup
Most exported tools are already on the intended Effect-native shape. The remaining cleanup is narrower than the old checklist implied.
Current spot cleanups worth tracking:
- [ ] `read.ts` — still bridges to Node stream / `readline` helpers and Promise-based binary detection
- [ ] `bash.ts` — already uses Effect child-process primitives; only keep tracking shell-specific platform bridges and parser/loading details as they come up
- [ ] `webfetch.ts` — already uses `HttpClient`; remaining work is limited to smaller boundary helpers like HTML text extraction
- [ ] `file/ripgrep.ts` — adjacent to tool migration; still has raw fs/process usage that affects `grep.ts` and `ls.ts`
- [ ] `patch/index.ts` — adjacent to tool migration; still has raw fs usage behind patch application
Notable items that are already effectively on the target path and do not need separate migration bullets right now:
- `apply_patch.ts`
- `grep.ts`
- `write.ts`
- `codesearch.ts`
- `websearch.ts`
- `ls.ts`
- `multiedit.ts`
- `edit.ts`
## Filesystem notes
Current raw fs users that still appear relevant here:
- `tool/read.ts``fs.createReadStream`, `readline`
- `file/ripgrep.ts``fs/promises`
- `patch/index.ts``fs`, `fs/promises`
+7 -5
View File
@@ -398,11 +398,13 @@ export namespace Agent {
}), }),
) )
export const defaultLayer = layer.pipe( export const defaultLayer = Layer.suspend(() =>
Layer.provide(Provider.defaultLayer), layer.pipe(
Layer.provide(Auth.defaultLayer), Layer.provide(Provider.defaultLayer),
Layer.provide(Config.defaultLayer), Layer.provide(Auth.defaultLayer),
Layer.provide(Skill.defaultLayer), Layer.provide(Config.defaultLayer),
Layer.provide(Skill.defaultLayer),
),
) )
const { runPromise } = makeRuntime(Service, defaultLayer) const { runPromise } = makeRuntime(Service, defaultLayer)
+19
View File
@@ -1,5 +1,6 @@
import path from "path" import path from "path"
import { Effect, Layer, Record, Result, Schema, Context } from "effect" import { Effect, Layer, Record, Result, Schema, Context } from "effect"
import { makeRuntime } from "@/effect/run-service"
import { zod } from "@/util/effect-zod" import { zod } from "@/util/effect-zod"
import { Global } from "../global" import { Global } from "../global"
import { AppFileSystem } from "../filesystem" import { AppFileSystem } from "../filesystem"
@@ -88,4 +89,22 @@ export namespace Auth {
) )
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer)) export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function get(providerID: string) {
return runPromise((service) => service.get(providerID))
}
export async function all(): Promise<Record<string, Info>> {
return runPromise((service) => service.all())
}
export async function set(key: string, info: Info) {
return runPromise((service) => service.set(key, info))
}
export async function remove(key: string) {
return runPromise((service) => service.remove(key))
}
} }
+1 -2
View File
@@ -1,11 +1,10 @@
import { AppRuntime } from "@/effect/app-runtime"
import { InstanceBootstrap } from "../project/bootstrap" import { InstanceBootstrap } from "../project/bootstrap"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
export async function bootstrap<T>(directory: string, cb: () => Promise<T>) { export async function bootstrap<T>(directory: string, cb: () => Promise<T>) {
return Instance.provide({ return Instance.provide({
directory, directory,
init: () => AppRuntime.runPromise(InstanceBootstrap), init: InstanceBootstrap,
fn: async () => { fn: async () => {
try { try {
const result = await cb() const result = await cb()
+6 -20
View File
@@ -12,7 +12,6 @@ import { Permission } from "../../../permission"
import { iife } from "../../../util/iife" import { iife } from "../../../util/iife"
import { bootstrap } from "../../bootstrap" import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd" import { cmd } from "../cmd"
import { AppRuntime } from "@/effect/app-runtime"
export const AgentCommand = cmd({ export const AgentCommand = cmd({
command: "agent <name>", command: "agent <name>",
@@ -72,17 +71,11 @@ export const AgentCommand = cmd({
}) })
async function getAvailableTools(agent: Agent.Info) { async function getAvailableTools(agent: Agent.Info) {
return AppRuntime.runPromise( const model = agent.model ?? (await Provider.defaultModel())
Effect.gen(function* () { return ToolRegistry.tools({
const provider = yield* Provider.Service ...model,
const registry = yield* ToolRegistry.Service agent,
const model = agent.model ?? (yield* provider.defaultModel()) })
return yield* registry.tools({
...model,
agent,
})
}),
)
} }
async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnType<typeof getAvailableTools>>) { async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnType<typeof getAvailableTools>>) {
@@ -125,14 +118,7 @@ function parseToolParams(input?: string) {
async function createToolContext(agent: Agent.Info) { async function createToolContext(agent: Agent.Info) {
const session = await Session.create({ title: `Debug tool run (${agent.name})` }) const session = await Session.create({ title: `Debug tool run (${agent.name})` })
const messageID = MessageID.ascending() const messageID = MessageID.ascending()
const model = const model = agent.model ?? (await Provider.defaultModel())
agent.model ??
(await AppRuntime.runPromise(
Effect.gen(function* () {
const provider = yield* Provider.Service
return yield* provider.defaultModel()
}),
))
const now = Date.now() const now = Date.now()
const message: MessageV2.Assistant = { const message: MessageV2.Assistant = {
id: messageID, id: messageID,
@@ -1,6 +1,5 @@
import { EOL } from "os" import { EOL } from "os"
import { Config } from "../../../config/config" import { Config } from "../../../config/config"
import { AppRuntime } from "@/effect/app-runtime"
import { bootstrap } from "../../bootstrap" import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd" import { cmd } from "../cmd"
@@ -10,7 +9,7 @@ export const ConfigCommand = cmd({
builder: (yargs) => yargs, builder: (yargs) => yargs,
async handler() { async handler() {
await bootstrap(process.cwd(), async () => { await bootstrap(process.cwd(), async () => {
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) const config = await Config.get()
process.stdout.write(JSON.stringify(config, null, 2) + EOL) process.stdout.write(JSON.stringify(config, null, 2) + EOL)
}) })
}, },
+4 -22
View File
@@ -1,6 +1,4 @@
import { EOL } from "os" import { EOL } from "os"
import { Effect } from "effect"
import { AppRuntime } from "@/effect/app-runtime"
import { File } from "../../../file" import { File } from "../../../file"
import { bootstrap } from "../../bootstrap" import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd" import { cmd } from "../cmd"
@@ -17,11 +15,7 @@ const FileSearchCommand = cmd({
}), }),
async handler(args) { async handler(args) {
await bootstrap(process.cwd(), async () => { await bootstrap(process.cwd(), async () => {
const results = await AppRuntime.runPromise( const results = await File.search({ query: args.query })
Effect.gen(function* () {
return yield* File.Service.use((svc) => svc.search({ query: args.query }))
}),
)
process.stdout.write(results.join(EOL) + EOL) process.stdout.write(results.join(EOL) + EOL)
}) })
}, },
@@ -38,11 +32,7 @@ const FileReadCommand = cmd({
}), }),
async handler(args) { async handler(args) {
await bootstrap(process.cwd(), async () => { await bootstrap(process.cwd(), async () => {
const content = await AppRuntime.runPromise( const content = await File.read(args.path)
Effect.gen(function* () {
return yield* File.Service.use((svc) => svc.read(args.path))
}),
)
process.stdout.write(JSON.stringify(content, null, 2) + EOL) process.stdout.write(JSON.stringify(content, null, 2) + EOL)
}) })
}, },
@@ -54,11 +44,7 @@ const FileStatusCommand = cmd({
builder: (yargs) => yargs, builder: (yargs) => yargs,
async handler() { async handler() {
await bootstrap(process.cwd(), async () => { await bootstrap(process.cwd(), async () => {
const status = await AppRuntime.runPromise( const status = await File.status()
Effect.gen(function* () {
return yield* File.Service.use((svc) => svc.status())
}),
)
process.stdout.write(JSON.stringify(status, null, 2) + EOL) process.stdout.write(JSON.stringify(status, null, 2) + EOL)
}) })
}, },
@@ -75,11 +61,7 @@ const FileListCommand = cmd({
}), }),
async handler(args) { async handler(args) {
await bootstrap(process.cwd(), async () => { await bootstrap(process.cwd(), async () => {
const files = await AppRuntime.runPromise( const files = await File.list(args.path)
Effect.gen(function* () {
return yield* File.Service.use((svc) => svc.list(args.path))
}),
)
process.stdout.write(JSON.stringify(files, null, 2) + EOL) process.stdout.write(JSON.stringify(files, null, 2) + EOL)
}) })
}, },
+5 -14
View File
@@ -1,6 +1,4 @@
import { LSP } from "../../../lsp" import { LSP } from "../../../lsp"
import { AppRuntime } from "../../../effect/app-runtime"
import { Effect } from "effect"
import { bootstrap } from "../../bootstrap" import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd" import { cmd } from "../cmd"
import { Log } from "../../../util/log" import { Log } from "../../../util/log"
@@ -21,16 +19,9 @@ const DiagnosticsCommand = cmd({
builder: (yargs) => yargs.positional("file", { type: "string", demandOption: true }), builder: (yargs) => yargs.positional("file", { type: "string", demandOption: true }),
async handler(args) { async handler(args) {
await bootstrap(process.cwd(), async () => { await bootstrap(process.cwd(), async () => {
const out = await AppRuntime.runPromise( await LSP.touchFile(args.file, true)
LSP.Service.use((lsp) => await sleep(1000)
Effect.gen(function* () { process.stdout.write(JSON.stringify(await LSP.diagnostics(), null, 2) + EOL)
yield* lsp.touchFile(args.file, true)
yield* Effect.sleep(1000)
return yield* lsp.diagnostics()
}),
),
)
process.stdout.write(JSON.stringify(out, null, 2) + EOL)
}) })
}, },
}) })
@@ -42,7 +33,7 @@ export const SymbolsCommand = cmd({
async handler(args) { async handler(args) {
await bootstrap(process.cwd(), async () => { await bootstrap(process.cwd(), async () => {
using _ = Log.Default.time("symbols") using _ = Log.Default.time("symbols")
const results = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.workspaceSymbol(args.query))) const results = await LSP.workspaceSymbol(args.query)
process.stdout.write(JSON.stringify(results, null, 2) + EOL) process.stdout.write(JSON.stringify(results, null, 2) + EOL)
}) })
}, },
@@ -55,7 +46,7 @@ export const DocumentSymbolsCommand = cmd({
async handler(args) { async handler(args) {
await bootstrap(process.cwd(), async () => { await bootstrap(process.cwd(), async () => {
using _ = Log.Default.time("document-symbols") using _ = Log.Default.time("document-symbols")
const results = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.documentSymbol(args.uri))) const results = await LSP.documentSymbol(args.uri)
process.stdout.write(JSON.stringify(results, null, 2) + EOL) process.stdout.write(JSON.stringify(results, null, 2) + EOL)
}) })
}, },
+6 -13
View File
@@ -1,5 +1,4 @@
import { EOL } from "os" import { EOL } from "os"
import { AppRuntime } from "../../../effect/app-runtime"
import { Ripgrep } from "../../../file/ripgrep" import { Ripgrep } from "../../../file/ripgrep"
import { Instance } from "../../../project/instance" import { Instance } from "../../../project/instance"
import { bootstrap } from "../../bootstrap" import { bootstrap } from "../../bootstrap"
@@ -77,18 +76,12 @@ const SearchCommand = cmd({
description: "Limit number of results", description: "Limit number of results",
}), }),
async handler(args) { async handler(args) {
await bootstrap(process.cwd(), async () => { const results = await Ripgrep.search({
const results = await AppRuntime.runPromise( cwd: process.cwd(),
Ripgrep.Service.use((svc) => pattern: args.pattern,
svc.search({ glob: args.glob as string[] | undefined,
cwd: Instance.directory, limit: args.limit,
pattern: args.pattern,
glob: args.glob as string[] | undefined,
limit: args.limit,
}),
),
)
process.stdout.write(JSON.stringify(results.items, null, 2) + EOL)
}) })
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
}, },
}) })
+1 -8
View File
@@ -1,6 +1,4 @@
import { EOL } from "os" import { EOL } from "os"
import { Effect } from "effect"
import { AppRuntime } from "@/effect/app-runtime"
import { Skill } from "../../../skill" import { Skill } from "../../../skill"
import { bootstrap } from "../../bootstrap" import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd" import { cmd } from "../cmd"
@@ -11,12 +9,7 @@ export const SkillCommand = cmd({
builder: (yargs) => yargs, builder: (yargs) => yargs,
async handler() { async handler() {
await bootstrap(process.cwd(), async () => { await bootstrap(process.cwd(), async () => {
const skills = await AppRuntime.runPromise( const skills = await Skill.all()
Effect.gen(function* () {
const skill = yield* Skill.Service
return yield* skill.all()
}),
)
process.stdout.write(JSON.stringify(skills, null, 2) + EOL) process.stdout.write(JSON.stringify(skills, null, 2) + EOL)
}) })
}, },
+46 -82
View File
@@ -15,8 +15,6 @@ import { Global } from "../../global"
import { modify, applyEdits } from "jsonc-parser" import { modify, applyEdits } from "jsonc-parser"
import { Filesystem } from "../../util/filesystem" import { Filesystem } from "../../util/filesystem"
import { Bus } from "../../bus" import { Bus } from "../../bus"
import { AppRuntime } from "../../effect/app-runtime"
import { Effect } from "effect"
function getAuthStatusIcon(status: MCP.AuthStatus): string { function getAuthStatusIcon(status: MCP.AuthStatus): string {
switch (status) { switch (status) {
@@ -52,47 +50,6 @@ function isMcpRemote(config: McpEntry): config is McpRemote {
return isMcpConfigured(config) && config.type === "remote" return isMcpConfigured(config) && config.type === "remote"
} }
function configuredServers(config: Config.Info) {
return Object.entries(config.mcp ?? {}).filter((entry): entry is [string, McpConfigured] => isMcpConfigured(entry[1]))
}
function oauthServers(config: Config.Info) {
return configuredServers(config).filter(
(entry): entry is [string, McpRemote] => isMcpRemote(entry[1]) && entry[1].oauth !== false,
)
}
async function listState() {
return AppRuntime.runPromise(
Effect.gen(function* () {
const cfg = yield* Config.Service
const mcp = yield* MCP.Service
const config = yield* cfg.get()
const statuses = yield* mcp.status()
const stored = yield* Effect.all(
Object.fromEntries(configuredServers(config).map(([name]) => [name, mcp.hasStoredTokens(name)])),
{ concurrency: "unbounded" },
)
return { config, statuses, stored }
}),
)
}
async function authState() {
return AppRuntime.runPromise(
Effect.gen(function* () {
const cfg = yield* Config.Service
const mcp = yield* MCP.Service
const config = yield* cfg.get()
const auth = yield* Effect.all(
Object.fromEntries(oauthServers(config).map(([name]) => [name, mcp.getAuthStatus(name)])),
{ concurrency: "unbounded" },
)
return { config, auth }
}),
)
}
export const McpCommand = cmd({ export const McpCommand = cmd({
command: "mcp", command: "mcp",
describe: "manage MCP (Model Context Protocol) servers", describe: "manage MCP (Model Context Protocol) servers",
@@ -118,8 +75,13 @@ export const McpListCommand = cmd({
UI.empty() UI.empty()
prompts.intro("MCP Servers") prompts.intro("MCP Servers")
const { config, statuses, stored } = await listState() const config = await Config.get()
const servers = configuredServers(config) const mcpServers = config.mcp ?? {}
const statuses = await MCP.status()
const servers = Object.entries(mcpServers).filter((entry): entry is [string, McpConfigured] =>
isMcpConfigured(entry[1]),
)
if (servers.length === 0) { if (servers.length === 0) {
prompts.log.warn("No MCP servers configured") prompts.log.warn("No MCP servers configured")
@@ -130,7 +92,7 @@ export const McpListCommand = cmd({
for (const [name, serverConfig] of servers) { for (const [name, serverConfig] of servers) {
const status = statuses[name] const status = statuses[name]
const hasOAuth = isMcpRemote(serverConfig) && !!serverConfig.oauth const hasOAuth = isMcpRemote(serverConfig) && !!serverConfig.oauth
const hasStoredTokens = stored[name] const hasStoredTokens = await MCP.hasStoredTokens(name)
let statusIcon: string let statusIcon: string
let statusText: string let statusText: string
@@ -190,11 +152,15 @@ export const McpAuthCommand = cmd({
UI.empty() UI.empty()
prompts.intro("MCP OAuth Authentication") prompts.intro("MCP OAuth Authentication")
const { config, auth } = await authState() const config = await Config.get()
const mcpServers = config.mcp ?? {} const mcpServers = config.mcp ?? {}
const servers = oauthServers(config)
if (servers.length === 0) { // Get OAuth-capable servers (remote servers with oauth not explicitly disabled)
const oauthServers = Object.entries(mcpServers).filter(
(entry): entry is [string, McpRemote] => isMcpRemote(entry[1]) && entry[1].oauth !== false,
)
if (oauthServers.length === 0) {
prompts.log.warn("No OAuth-capable MCP servers configured") prompts.log.warn("No OAuth-capable MCP servers configured")
prompts.log.info("Remote MCP servers support OAuth by default. Add a remote server in opencode.json:") prompts.log.info("Remote MCP servers support OAuth by default. Add a remote server in opencode.json:")
prompts.log.info(` prompts.log.info(`
@@ -211,17 +177,19 @@ export const McpAuthCommand = cmd({
let serverName = args.name let serverName = args.name
if (!serverName) { if (!serverName) {
// Build options with auth status // Build options with auth status
const options = servers.map(([name, cfg]) => { const options = await Promise.all(
const authStatus = auth[name] oauthServers.map(async ([name, cfg]) => {
const icon = getAuthStatusIcon(authStatus) const authStatus = await MCP.getAuthStatus(name)
const statusText = getAuthStatusText(authStatus) const icon = getAuthStatusIcon(authStatus)
const url = cfg.url const statusText = getAuthStatusText(authStatus)
return { const url = cfg.url
label: `${icon} ${name} (${statusText})`, return {
value: name, label: `${icon} ${name} (${statusText})`,
hint: url, value: name,
} hint: url,
}) }
}),
)
const selected = await prompts.select({ const selected = await prompts.select({
message: "Select MCP server to authenticate", message: "Select MCP server to authenticate",
@@ -245,8 +213,7 @@ export const McpAuthCommand = cmd({
} }
// Check if already authenticated // Check if already authenticated
const authStatus = const authStatus = await MCP.getAuthStatus(serverName)
auth[serverName] ?? (await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.getAuthStatus(serverName))))
if (authStatus === "authenticated") { if (authStatus === "authenticated") {
const confirm = await prompts.confirm({ const confirm = await prompts.confirm({
message: `${serverName} already has valid credentials. Re-authenticate?`, message: `${serverName} already has valid credentials. Re-authenticate?`,
@@ -273,7 +240,7 @@ export const McpAuthCommand = cmd({
}) })
try { try {
const status = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.authenticate(serverName))) const status = await MCP.authenticate(serverName)
if (status.status === "connected") { if (status.status === "connected") {
spinner.stop("Authentication successful!") spinner.stop("Authentication successful!")
@@ -322,17 +289,22 @@ export const McpAuthListCommand = cmd({
UI.empty() UI.empty()
prompts.intro("MCP OAuth Status") prompts.intro("MCP OAuth Status")
const { config, auth } = await authState() const config = await Config.get()
const servers = oauthServers(config) const mcpServers = config.mcp ?? {}
if (servers.length === 0) { // Get OAuth-capable servers
const oauthServers = Object.entries(mcpServers).filter(
(entry): entry is [string, McpRemote] => isMcpRemote(entry[1]) && entry[1].oauth !== false,
)
if (oauthServers.length === 0) {
prompts.log.warn("No OAuth-capable MCP servers configured") prompts.log.warn("No OAuth-capable MCP servers configured")
prompts.outro("Done") prompts.outro("Done")
return return
} }
for (const [name, serverConfig] of servers) { for (const [name, serverConfig] of oauthServers) {
const authStatus = auth[name] const authStatus = await MCP.getAuthStatus(name)
const icon = getAuthStatusIcon(authStatus) const icon = getAuthStatusIcon(authStatus)
const statusText = getAuthStatusText(authStatus) const statusText = getAuthStatusText(authStatus)
const url = serverConfig.url const url = serverConfig.url
@@ -340,7 +312,7 @@ export const McpAuthListCommand = cmd({
prompts.log.info(`${icon} ${name} ${UI.Style.TEXT_DIM}${statusText}\n ${UI.Style.TEXT_DIM}${url}`) prompts.log.info(`${icon} ${name} ${UI.Style.TEXT_DIM}${statusText}\n ${UI.Style.TEXT_DIM}${url}`)
} }
prompts.outro(`${servers.length} OAuth-capable server(s)`) prompts.outro(`${oauthServers.length} OAuth-capable server(s)`)
}, },
}) })
}, },
@@ -362,7 +334,7 @@ export const McpLogoutCommand = cmd({
prompts.intro("MCP OAuth Logout") prompts.intro("MCP OAuth Logout")
const authPath = path.join(Global.Path.data, "mcp-auth.json") const authPath = path.join(Global.Path.data, "mcp-auth.json")
const credentials = await AppRuntime.runPromise(McpAuth.Service.use((auth) => auth.all())) const credentials = await McpAuth.all()
const serverNames = Object.keys(credentials) const serverNames = Object.keys(credentials)
if (serverNames.length === 0) { if (serverNames.length === 0) {
@@ -400,7 +372,7 @@ export const McpLogoutCommand = cmd({
return return
} }
await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.removeAuth(serverName))) await MCP.removeAuth(serverName)
prompts.log.success(`Removed OAuth credentials for ${serverName}`) prompts.log.success(`Removed OAuth credentials for ${serverName}`)
prompts.outro("Done") prompts.outro("Done")
}, },
@@ -623,7 +595,7 @@ export const McpDebugCommand = cmd({
UI.empty() UI.empty()
prompts.intro("MCP OAuth Debug") prompts.intro("MCP OAuth Debug")
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) const config = await Config.get()
const mcpServers = config.mcp ?? {} const mcpServers = config.mcp ?? {}
const serverName = args.name const serverName = args.name
@@ -650,18 +622,10 @@ export const McpDebugCommand = cmd({
prompts.log.info(`URL: ${serverConfig.url}`) prompts.log.info(`URL: ${serverConfig.url}`)
// Check stored auth status // Check stored auth status
const { authStatus, entry } = await AppRuntime.runPromise( const authStatus = await MCP.getAuthStatus(serverName)
Effect.gen(function* () {
const mcp = yield* MCP.Service
const auth = yield* McpAuth.Service
return {
authStatus: yield* mcp.getAuthStatus(serverName),
entry: yield* auth.get(serverName),
}
}),
)
prompts.log.info(`Auth status: ${getAuthStatusIcon(authStatus)} ${getAuthStatusText(authStatus)}`) prompts.log.info(`Auth status: ${getAuthStatusIcon(authStatus)} ${getAuthStatusText(authStatus)}`)
const entry = await McpAuth.get(serverName)
if (entry?.tokens) { if (entry?.tokens) {
prompts.log.info(` Access token: ${entry.tokens.accessToken.substring(0, 20)}...`) prompts.log.info(` Access token: ${entry.tokens.accessToken.substring(0, 20)}...`)
if (entry.tokens.expiresAt) { if (entry.tokens.expiresAt) {
+31 -41
View File
@@ -6,8 +6,6 @@ import { ModelsDev } from "../../provider/models"
import { cmd } from "./cmd" import { cmd } from "./cmd"
import { UI } from "../ui" import { UI } from "../ui"
import { EOL } from "os" import { EOL } from "os"
import { AppRuntime } from "@/effect/app-runtime"
import { Effect } from "effect"
export const ModelsCommand = cmd({ export const ModelsCommand = cmd({
command: "models [provider]", command: "models [provider]",
@@ -37,51 +35,43 @@ export const ModelsCommand = cmd({
await Instance.provide({ await Instance.provide({
directory: process.cwd(), directory: process.cwd(),
async fn() { async fn() {
await AppRuntime.runPromise( const providers = await Provider.list()
Effect.gen(function* () {
const svc = yield* Provider.Service
const providers = yield* svc.list()
const print = (providerID: ProviderID, verbose?: boolean) => { function printModels(providerID: ProviderID, verbose?: boolean) {
const provider = providers[providerID] const provider = providers[providerID]
const sorted = Object.entries(provider.models).sort(([a], [b]) => a.localeCompare(b)) const sortedModels = Object.entries(provider.models).sort(([a], [b]) => a.localeCompare(b))
for (const [modelID, model] of sorted) { for (const [modelID, model] of sortedModels) {
process.stdout.write(`${providerID}/${modelID}`) process.stdout.write(`${providerID}/${modelID}`)
process.stdout.write(EOL) process.stdout.write(EOL)
if (verbose) { if (verbose) {
process.stdout.write(JSON.stringify(model, null, 2)) process.stdout.write(JSON.stringify(model, null, 2))
process.stdout.write(EOL) process.stdout.write(EOL)
}
}
} }
}
}
if (args.provider) { if (args.provider) {
const providerID = ProviderID.make(args.provider) const provider = providers[ProviderID.make(args.provider)]
const provider = providers[providerID] if (!provider) {
if (!provider) { UI.error(`Provider not found: ${args.provider}`)
yield* Effect.sync(() => UI.error(`Provider not found: ${args.provider}`)) return
return }
}
yield* Effect.sync(() => print(providerID, args.verbose)) printModels(ProviderID.make(args.provider), args.verbose)
return return
} }
const ids = Object.keys(providers).sort((a, b) => { const providerIDs = Object.keys(providers).sort((a, b) => {
const aIsOpencode = a.startsWith("opencode") const aIsOpencode = a.startsWith("opencode")
const bIsOpencode = b.startsWith("opencode") const bIsOpencode = b.startsWith("opencode")
if (aIsOpencode && !bIsOpencode) return -1 if (aIsOpencode && !bIsOpencode) return -1
if (!aIsOpencode && bIsOpencode) return 1 if (!aIsOpencode && bIsOpencode) return 1
return a.localeCompare(b) return a.localeCompare(b)
}) })
yield* Effect.sync(() => { for (const providerID of providerIDs) {
for (const providerID of ids) { printModels(ProviderID.make(providerID), args.verbose)
print(ProviderID.make(providerID), args.verbose) }
}
})
}),
)
}, },
}) })
}, },
+14 -46
View File
@@ -1,5 +1,4 @@
import { Auth } from "../../auth" import { Auth } from "../../auth"
import { AppRuntime } from "../../effect/app-runtime"
import { cmd } from "./cmd" import { cmd } from "./cmd"
import * as prompts from "@clack/prompts" import * as prompts from "@clack/prompts"
import { UI } from "../ui" import { UI } from "../ui"
@@ -14,18 +13,9 @@ import { Instance } from "../../project/instance"
import type { Hooks } from "@opencode-ai/plugin" import type { Hooks } from "@opencode-ai/plugin"
import { Process } from "../../util/process" import { Process } from "../../util/process"
import { text } from "node:stream/consumers" import { text } from "node:stream/consumers"
import { Effect } from "effect"
type PluginAuth = NonNullable<Hooks["auth"]> type PluginAuth = NonNullable<Hooks["auth"]>
const put = (key: string, info: Auth.Info) =>
AppRuntime.runPromise(
Effect.gen(function* () {
const auth = yield* Auth.Service
yield* auth.set(key, info)
}),
)
async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, methodName?: string): Promise<boolean> { async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, methodName?: string): Promise<boolean> {
let index = 0 let index = 0
if (methodName) { if (methodName) {
@@ -103,7 +93,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
const saveProvider = result.provider ?? provider const saveProvider = result.provider ?? provider
if ("refresh" in result) { if ("refresh" in result) {
const { type: _, provider: __, refresh, access, expires, ...extraFields } = result const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
await put(saveProvider, { await Auth.set(saveProvider, {
type: "oauth", type: "oauth",
refresh, refresh,
access, access,
@@ -112,7 +102,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
}) })
} }
if ("key" in result) { if ("key" in result) {
await put(saveProvider, { await Auth.set(saveProvider, {
type: "api", type: "api",
key: result.key, key: result.key,
}) })
@@ -135,7 +125,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
const saveProvider = result.provider ?? provider const saveProvider = result.provider ?? provider
if ("refresh" in result) { if ("refresh" in result) {
const { type: _, provider: __, refresh, access, expires, ...extraFields } = result const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
await put(saveProvider, { await Auth.set(saveProvider, {
type: "oauth", type: "oauth",
refresh, refresh,
access, access,
@@ -144,7 +134,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
}) })
} }
if ("key" in result) { if ("key" in result) {
await put(saveProvider, { await Auth.set(saveProvider, {
type: "api", type: "api",
key: result.key, key: result.key,
}) })
@@ -158,12 +148,6 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
} }
if (method.type === "api") { if (method.type === "api") {
const key = await prompts.password({
message: "Enter your API key",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(key)) throw new UI.CancelledError()
if (method.authorize) { if (method.authorize) {
const result = await method.authorize(inputs) const result = await method.authorize(inputs)
if (result.type === "failed") { if (result.type === "failed") {
@@ -171,9 +155,9 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
} }
if (result.type === "success") { if (result.type === "success") {
const saveProvider = result.provider ?? provider const saveProvider = result.provider ?? provider
await put(saveProvider, { await Auth.set(saveProvider, {
type: "api", type: "api",
key: result.key ?? key, key: result.key,
}) })
prompts.log.success("Login successful") prompts.log.success("Login successful")
} }
@@ -231,12 +215,7 @@ export const ProvidersListCommand = cmd({
const homedir = os.homedir() const homedir = os.homedir()
const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath
prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`) prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
const results = await AppRuntime.runPromise( const results = Object.entries(await Auth.all())
Effect.gen(function* () {
const auth = yield* Auth.Service
return Object.entries(yield* auth.all())
}),
)
const database = await ModelsDev.get() const database = await ModelsDev.get()
for (const [providerID, result] of results) { for (const [providerID, result] of results) {
@@ -315,7 +294,7 @@ export const ProvidersLoginCommand = cmd({
prompts.outro("Done") prompts.outro("Done")
return return
} }
await put(url, { await Auth.set(url, {
type: "wellknown", type: "wellknown",
key: wellknown.auth.env, key: wellknown.auth.env,
token: token.trim(), token: token.trim(),
@@ -326,7 +305,7 @@ export const ProvidersLoginCommand = cmd({
} }
await ModelsDev.refresh(true).catch(() => {}) await ModelsDev.refresh(true).catch(() => {})
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) const config = await Config.get()
const disabled = new Set(config.disabled_providers ?? []) const disabled = new Set(config.disabled_providers ?? [])
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
@@ -462,7 +441,7 @@ export const ProvidersLoginCommand = cmd({
validate: (x) => (x && x.length > 0 ? undefined : "Required"), validate: (x) => (x && x.length > 0 ? undefined : "Required"),
}) })
if (prompts.isCancel(key)) throw new UI.CancelledError() if (prompts.isCancel(key)) throw new UI.CancelledError()
await put(provider, { await Auth.set(provider, {
type: "api", type: "api",
key, key,
}) })
@@ -478,33 +457,22 @@ export const ProvidersLogoutCommand = cmd({
describe: "log out from a configured provider", describe: "log out from a configured provider",
async handler(_args) { async handler(_args) {
UI.empty() UI.empty()
const credentials: Array<[string, Auth.Info]> = await AppRuntime.runPromise( const credentials = await Auth.all().then((x) => Object.entries(x))
Effect.gen(function* () {
const auth = yield* Auth.Service
return Object.entries(yield* auth.all())
}),
)
prompts.intro("Remove credential") prompts.intro("Remove credential")
if (credentials.length === 0) { if (credentials.length === 0) {
prompts.log.error("No credentials found") prompts.log.error("No credentials found")
return return
} }
const database = await ModelsDev.get() const database = await ModelsDev.get()
const selected = await prompts.select({ const providerID = await prompts.select({
message: "Select provider", message: "Select provider",
options: credentials.map(([key, value]) => ({ options: credentials.map(([key, value]) => ({
label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")", label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
value: key, value: key,
})), })),
}) })
if (prompts.isCancel(selected)) throw new UI.CancelledError() if (prompts.isCancel(providerID)) throw new UI.CancelledError()
const providerID = selected as string await Auth.remove(providerID)
await AppRuntime.runPromise(
Effect.gen(function* () {
const auth = yield* Auth.Service
yield* auth.remove(providerID)
}),
)
prompts.outro("Logout successful") prompts.outro("Logout successful")
}, },
}) })
+61 -2
View File
@@ -1,7 +1,6 @@
import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { Clipboard } from "@tui/util/clipboard" import { Clipboard } from "@tui/util/clipboard"
import { Selection } from "@tui/util/selection" import { Selection } from "@tui/util/selection"
import { Terminal } from "@tui/util/terminal"
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core" import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
import { RouteProvider, useRoute } from "@tui/context/route" import { RouteProvider, useRoute } from "@tui/context/route"
import { import {
@@ -61,6 +60,66 @@ import { TuiConfig } from "@/config/tui"
import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin" import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin"
import { FormatError, FormatUnknownError } from "@/cli/error" import { FormatError, FormatUnknownError } from "@/cli/error"
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
// can't set raw mode if not a TTY
if (!process.stdin.isTTY) return "dark"
return new Promise((resolve) => {
let timeout: NodeJS.Timeout
const cleanup = () => {
process.stdin.setRawMode(false)
process.stdin.removeListener("data", handler)
clearTimeout(timeout)
}
const handler = (data: Buffer) => {
const str = data.toString()
const match = str.match(/\x1b]11;([^\x07\x1b]+)/)
if (match) {
cleanup()
const color = match[1]
// Parse RGB values from color string
// Formats: rgb:RR/GG/BB or #RRGGBB or rgb(R,G,B)
let r = 0,
g = 0,
b = 0
if (color.startsWith("rgb:")) {
const parts = color.substring(4).split("/")
r = parseInt(parts[0], 16) >> 8 // Convert 16-bit to 8-bit
g = parseInt(parts[1], 16) >> 8 // Convert 16-bit to 8-bit
b = parseInt(parts[2], 16) >> 8 // Convert 16-bit to 8-bit
} else if (color.startsWith("#")) {
r = parseInt(color.substring(1, 3), 16)
g = parseInt(color.substring(3, 5), 16)
b = parseInt(color.substring(5, 7), 16)
} else if (color.startsWith("rgb(")) {
const parts = color.substring(4, color.length - 1).split(",")
r = parseInt(parts[0])
g = parseInt(parts[1])
b = parseInt(parts[2])
}
// Calculate luminance using relative luminance formula
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
// Determine if dark or light based on luminance threshold
resolve(luminance > 0.5 ? "light" : "dark")
}
}
process.stdin.setRawMode(true)
process.stdin.on("data", handler)
process.stdout.write("\x1b]11;?\x07")
timeout = setTimeout(() => {
cleanup()
resolve("dark")
}, 1000)
})
}
import type { EventSource } from "./context/sdk" import type { EventSource } from "./context/sdk"
import { DialogVariant } from "./component/dialog-variant" import { DialogVariant } from "./component/dialog-variant"
@@ -119,7 +178,7 @@ export function tui(input: {
const unguard = win32InstallCtrlCGuard() const unguard = win32InstallCtrlCGuard()
win32DisableProcessedInput() win32DisableProcessedInput()
const mode = await Terminal.getTerminalBackgroundColor() const mode = await getTerminalBackgroundColor()
// Re-clear after getTerminalBackgroundColor() — setRawMode(false) restores // Re-clear after getTerminalBackgroundColor() — setRawMode(false) restores
// the original console mode which re-enables ENABLE_PROCESSED_INPUT. // the original console mode which re-enables ENABLE_PROCESSED_INPUT.
@@ -9,12 +9,6 @@ import { setTimeout as sleep } from "node:timers/promises"
import { useSDK } from "../context/sdk" import { useSDK } from "../context/sdk"
import { useToast } from "../ui/toast" import { useToast } from "../ui/toast"
type Adaptor = {
type: string
name: string
description: string
}
function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID: string) { function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID: string) {
return createOpencodeClient({ return createOpencodeClient({
baseUrl: sdk.url, baseUrl: sdk.url,
@@ -69,27 +63,9 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
const sdk = useSDK() const sdk = useSDK()
const toast = useToast() const toast = useToast()
const [creating, setCreating] = createSignal<string>() const [creating, setCreating] = createSignal<string>()
const [adaptors, setAdaptors] = createSignal<Adaptor[]>()
onMount(() => { onMount(() => {
dialog.setSize("medium") dialog.setSize("medium")
void (async () => {
const dir = sync.path.directory || sdk.directory
const url = new URL("/experimental/workspace/adaptor", sdk.url)
if (dir) url.searchParams.set("directory", dir)
const res = await sdk
.fetch(url)
.then((x) => x.json() as Promise<Adaptor[]>)
.catch(() => undefined)
if (!res) {
toast.show({
message: "Failed to load workspace adaptors",
variant: "error",
})
return
}
setAdaptors(res)
})()
}) })
const options = createMemo(() => { const options = createMemo(() => {
@@ -103,21 +79,13 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
}, },
] ]
} }
const list = adaptors() return [
if (!list) { {
return [ title: "Worktree",
{ value: "worktree" as const,
title: "Loading workspaces...", description: "Create a local git worktree",
value: "loading" as const, },
description: "Fetching available workspace adaptors", ]
},
]
}
return list.map((item) => ({
title: item.name,
value: item.type,
description: item.description,
}))
}) })
const create = async (type: string) => { const create = async (type: string) => {
@@ -145,7 +113,7 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
skipFilter={true} skipFilter={true}
options={options()} options={options()}
onSelect={(option) => { onSelect={(option) => {
if (option.value === "creating" || option.value === "loading") return if (option.value === "creating") return
void create(option.value) void create(option.value)
}} }}
/> />
@@ -2,28 +2,6 @@ import { RGBA } from "@opentui/core"
export namespace Terminal { export namespace Terminal {
export type Colors = Awaited<ReturnType<typeof colors>> export type Colors = Awaited<ReturnType<typeof colors>>
function parse(color: string): RGBA | null {
if (color.startsWith("rgb:")) {
const parts = color.substring(4).split("/")
return RGBA.fromInts(parseInt(parts[0], 16) >> 8, parseInt(parts[1], 16) >> 8, parseInt(parts[2], 16) >> 8, 255)
}
if (color.startsWith("#")) {
return RGBA.fromHex(color)
}
if (color.startsWith("rgb(")) {
const parts = color.substring(4, color.length - 1).split(",")
return RGBA.fromInts(parseInt(parts[0]), parseInt(parts[1]), parseInt(parts[2]), 255)
}
return null
}
function mode(bg: RGBA | null): "dark" | "light" {
if (!bg) return "dark"
const luminance = (0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b) / 255
return luminance > 0.5 ? "light" : "dark"
}
/** /**
* Query terminal colors including background, foreground, and palette (0-15). * Query terminal colors including background, foreground, and palette (0-15).
* Uses OSC escape sequences to retrieve actual terminal color values. * Uses OSC escape sequences to retrieve actual terminal color values.
@@ -53,26 +31,46 @@ export namespace Terminal {
clearTimeout(timeout) clearTimeout(timeout)
} }
const parseColor = (colorStr: string): RGBA | null => {
if (colorStr.startsWith("rgb:")) {
const parts = colorStr.substring(4).split("/")
return RGBA.fromInts(
parseInt(parts[0], 16) >> 8, // Convert 16-bit to 8-bit
parseInt(parts[1], 16) >> 8,
parseInt(parts[2], 16) >> 8,
255,
)
}
if (colorStr.startsWith("#")) {
return RGBA.fromHex(colorStr)
}
if (colorStr.startsWith("rgb(")) {
const parts = colorStr.substring(4, colorStr.length - 1).split(",")
return RGBA.fromInts(parseInt(parts[0]), parseInt(parts[1]), parseInt(parts[2]), 255)
}
return null
}
const handler = (data: Buffer) => { const handler = (data: Buffer) => {
const str = data.toString() const str = data.toString()
// Match OSC 11 (background color) // Match OSC 11 (background color)
const bgMatch = str.match(/\x1b]11;([^\x07\x1b]+)/) const bgMatch = str.match(/\x1b]11;([^\x07\x1b]+)/)
if (bgMatch) { if (bgMatch) {
background = parse(bgMatch[1]) background = parseColor(bgMatch[1])
} }
// Match OSC 10 (foreground color) // Match OSC 10 (foreground color)
const fgMatch = str.match(/\x1b]10;([^\x07\x1b]+)/) const fgMatch = str.match(/\x1b]10;([^\x07\x1b]+)/)
if (fgMatch) { if (fgMatch) {
foreground = parse(fgMatch[1]) foreground = parseColor(fgMatch[1])
} }
// Match OSC 4 (palette colors) // Match OSC 4 (palette colors)
const paletteMatches = str.matchAll(/\x1b]4;(\d+);([^\x07\x1b]+)/g) const paletteMatches = str.matchAll(/\x1b]4;(\d+);([^\x07\x1b]+)/g)
for (const match of paletteMatches) { for (const match of paletteMatches) {
const index = parseInt(match[1]) const index = parseInt(match[1])
const color = parse(match[2]) const color = parseColor(match[2])
if (color) paletteColors[index] = color if (color) paletteColors[index] = color
} }
@@ -102,36 +100,15 @@ export namespace Terminal {
}) })
} }
// Keep startup mode detection separate from `colors()`: the TUI boot path only
// needs OSC 11 and should resolve on the first background response instead of
// waiting on the full palette query used by system theme generation.
export async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { export async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
if (!process.stdin.isTTY) return "dark" const result = await colors()
if (!result.background) return "dark"
return new Promise((resolve) => { const { r, g, b } = result.background
let timeout: NodeJS.Timeout // Calculate luminance using relative luminance formula
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
const cleanup = () => { // Determine if dark or light based on luminance threshold
process.stdin.setRawMode(false) return luminance > 0.5 ? "light" : "dark"
process.stdin.removeListener("data", handler)
clearTimeout(timeout)
}
const handler = (data: Buffer) => {
const match = data.toString().match(/\x1b]11;([^\x07\x1b]+)/)
if (!match) return
cleanup()
resolve(mode(parse(match[1])))
}
process.stdin.setRawMode(true)
process.stdin.on("data", handler)
process.stdout.write("\x1b]11;?\x07")
timeout = setTimeout(() => {
cleanup()
resolve("dark")
}, 1000)
})
} }
} }
+3 -3
View File
@@ -7,10 +7,10 @@ import { Rpc } from "@/util/rpc"
import { upgrade } from "@/cli/upgrade" import { upgrade } from "@/cli/upgrade"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { GlobalBus } from "@/bus/global" import { GlobalBus } from "@/bus/global"
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { writeHeapSnapshot } from "node:v8" import { writeHeapSnapshot } from "node:v8"
import { Heap } from "@/cli/heap" import { Heap } from "@/cli/heap"
import { AppRuntime } from "@/effect/app-runtime"
await Log.init({ await Log.init({
print: process.argv.includes("--print-logs"), print: process.argv.includes("--print-logs"),
@@ -74,14 +74,14 @@ export const rpc = {
async checkUpgrade(input: { directory: string }) { async checkUpgrade(input: { directory: string }) {
await Instance.provide({ await Instance.provide({
directory: input.directory, directory: input.directory,
init: () => AppRuntime.runPromise(InstanceBootstrap), init: InstanceBootstrap,
fn: async () => { fn: async () => {
await upgrade().catch(() => {}) await upgrade().catch(() => {})
}, },
}) })
}, },
async reload() { async reload() {
await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.invalidate(true))) await Config.invalidate(true)
}, },
async shutdown() { async shutdown() {
Log.Default.info("worker shutting down") Log.Default.info("worker shutting down")
+1 -2
View File
@@ -1,6 +1,5 @@
import type { Argv, InferredOptionTypes } from "yargs" import type { Argv, InferredOptionTypes } from "yargs"
import { Config } from "../config/config" import { Config } from "../config/config"
import { AppRuntime } from "@/effect/app-runtime"
const options = { const options = {
port: { port: {
@@ -38,7 +37,7 @@ export function withNetworkOptions<T>(yargs: Argv<T>) {
} }
export async function resolveNetworkOptions(args: NetworkOptions) { export async function resolveNetworkOptions(args: NetworkOptions) {
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal())) const config = await Config.getGlobal()
const portExplicitlySet = process.argv.includes("--port") const portExplicitlySet = process.argv.includes("--port")
const hostnameExplicitlySet = process.argv.includes("--hostname") const hostnameExplicitlySet = process.argv.includes("--hostname")
const mdnsExplicitlySet = process.argv.includes("--mdns") const mdnsExplicitlySet = process.argv.includes("--mdns")
+1 -1
View File
@@ -5,7 +5,7 @@ import { Flag } from "@/flag/flag"
import { Installation } from "@/installation" import { Installation } from "@/installation"
export async function upgrade() { export async function upgrade() {
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal())) const config = await Config.getGlobal()
const method = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method())) const method = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method()))
const latest = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest(method))).catch(() => {}) const latest = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest(method))).catch(() => {})
if (!latest) return if (!latest) return
+2 -4
View File
@@ -183,9 +183,7 @@ export namespace Command {
}), }),
) )
export const defaultLayer = layer.pipe( export const defaultLayer = Layer.suspend(() =>
Layer.provide(Config.defaultLayer), layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(MCP.defaultLayer), Layer.provide(Skill.defaultLayer)),
Layer.provide(MCP.defaultLayer),
Layer.provide(Skill.defaultLayer),
) )
} }
+92 -91
View File
@@ -22,18 +22,21 @@ import { Instance, type InstanceContext } from "../project/instance"
import { LSPServer } from "../lsp/server" import { LSPServer } from "../lsp/server"
import { Installation } from "@/installation" import { Installation } from "@/installation"
import { ConfigMarkdown } from "./markdown" import { ConfigMarkdown } from "./markdown"
import { existsSync } from "fs" import { constants, existsSync } from "fs"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global" import { GlobalBus } from "@/bus/global"
import { Event } from "../server/event" import { Event } from "../server/event"
import { Glob } from "../util/glob" import { Glob } from "../util/glob"
import { iife } from "@/util/iife"
import { Account } from "@/account" import { Account } from "@/account"
import { isRecord } from "@/util/record" import { isRecord } from "@/util/record"
import { ConfigPaths } from "./paths" import { ConfigPaths } from "./paths"
import { Filesystem } from "@/util/filesystem"
import type { ConsoleState } from "./console-state" import type { ConsoleState } from "./console-state"
import { AppFileSystem } from "@/filesystem" import { AppFileSystem } from "@/filesystem"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { Context, Duration, Effect, Exit, Fiber, Layer, Option } from "effect" import { makeRuntime } from "@/effect/run-service"
import { Duration, Effect, Layer, Option, Context } from "effect"
import { Flock } from "@/util/flock" import { Flock } from "@/util/flock"
import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared" import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
import { Npm } from "@/npm" import { Npm } from "@/npm"
@@ -137,11 +140,53 @@ export namespace Config {
} }
export type InstallInput = { export type InstallInput = {
signal?: AbortSignal
waitTick?: (input: { dir: string; attempt: number; delay: number; waited: number }) => void | Promise<void> waitTick?: (input: { dir: string; attempt: number; delay: number; waited: number }) => void | Promise<void>
} }
type Package = { export async function installDependencies(dir: string, input?: InstallInput) {
dependencies?: Record<string, string> if (!(await isWritable(dir))) return
await using _ = await Flock.acquire(`config-install:${Filesystem.resolve(dir)}`, {
signal: input?.signal,
onWait: (tick) =>
input?.waitTick?.({
dir,
attempt: tick.attempt,
delay: tick.delay,
waited: tick.waited,
}),
})
input?.signal?.throwIfAborted()
const pkg = path.join(dir, "package.json")
const target = Installation.isLocal() ? "*" : Installation.VERSION
const json = await Filesystem.readJson<{ dependencies?: Record<string, string> }>(pkg).catch(() => ({
dependencies: {},
}))
json.dependencies = {
...json.dependencies,
"@opencode-ai/plugin": target,
}
await Filesystem.writeJson(pkg, json)
const gitignore = path.join(dir, ".gitignore")
const ignore = await Filesystem.exists(gitignore)
if (!ignore) {
await Filesystem.write(
gitignore,
["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"),
)
}
await Npm.install(dir)
}
async function isWritable(dir: string) {
try {
await fsNode.access(dir, constants.W_OK)
return true
} catch {
return false
}
} }
function rel(item: string, patterns: string[]) { function rel(item: string, patterns: string[]) {
@@ -1066,7 +1111,7 @@ export namespace Config {
type State = { type State = {
config: Info config: Info
directories: string[] directories: string[]
deps: Fiber.Fiber<void, never>[] deps: Promise<void>[]
consoleState: ConsoleState consoleState: ConsoleState
} }
@@ -1074,7 +1119,6 @@ export namespace Config {
readonly get: () => Effect.Effect<Info> readonly get: () => Effect.Effect<Info>
readonly getGlobal: () => Effect.Effect<Info> readonly getGlobal: () => Effect.Effect<Info>
readonly getConsoleState: () => Effect.Effect<ConsoleState> readonly getConsoleState: () => Effect.Effect<ConsoleState>
readonly installDependencies: (dir: string, input?: InstallInput) => Effect.Effect<void, AppFileSystem.Error>
readonly update: (config: Info) => Effect.Effect<void> readonly update: (config: Info) => Effect.Effect<void>
readonly updateGlobal: (config: Info) => Effect.Effect<Info> readonly updateGlobal: (config: Info) => Effect.Effect<Info>
readonly invalidate: (wait?: boolean) => Effect.Effect<void> readonly invalidate: (wait?: boolean) => Effect.Effect<void>
@@ -1276,74 +1320,6 @@ export namespace Config {
return yield* cachedGlobal return yield* cachedGlobal
}) })
const install = Effect.fnUntraced(function* (dir: string) {
const pkg = path.join(dir, "package.json")
const gitignore = path.join(dir, ".gitignore")
const plugin = path.join(dir, "node_modules", "@opencode-ai", "plugin", "package.json")
const target = Installation.isLocal() ? "*" : Installation.VERSION
const json = yield* fs.readJson(pkg).pipe(
Effect.catch(() => Effect.succeed({} satisfies Package)),
Effect.map((x): Package => (isRecord(x) ? (x as Package) : {})),
)
const hasDep = json.dependencies?.["@opencode-ai/plugin"] === target
const hasIgnore = yield* fs.existsSafe(gitignore)
const hasPkg = yield* fs.existsSafe(plugin)
if (!hasDep) {
yield* fs.writeJson(pkg, {
...json,
dependencies: {
...json.dependencies,
"@opencode-ai/plugin": target,
},
})
}
if (!hasIgnore) {
yield* fs.writeFileString(
gitignore,
["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"),
)
}
if (hasDep && hasIgnore && hasPkg) return
yield* Effect.promise(() => Npm.install(dir))
})
const installDependencies = Effect.fn("Config.installDependencies")(function* (
dir: string,
input?: InstallInput,
) {
if (
!(yield* fs.access(dir, { writable: true }).pipe(
Effect.as(true),
Effect.orElseSucceed(() => false),
))
)
return
const key =
process.platform === "win32" ? "config-install:win32" : `config-install:${AppFileSystem.resolve(dir)}`
yield* Effect.acquireUseRelease(
Effect.promise((signal) =>
Flock.acquire(key, {
signal,
onWait: (tick) =>
input?.waitTick?.({
dir,
attempt: tick.attempt,
delay: tick.delay,
waited: tick.waited,
}),
}),
),
() => install(dir),
(lease) => Effect.promise(() => lease.release()),
)
})
const loadInstanceState = Effect.fnUntraced(function* (ctx: InstanceContext) { const loadInstanceState = Effect.fnUntraced(function* (ctx: InstanceContext) {
const auth = yield* authSvc.all().pipe(Effect.orDie) const auth = yield* authSvc.all().pipe(Effect.orDie)
@@ -1426,7 +1402,7 @@ export namespace Config {
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR }) log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
} }
const deps: Fiber.Fiber<void, never>[] = [] const deps: Promise<void>[] = []
for (const dir of unique(directories)) { for (const dir of unique(directories)) {
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) { if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
@@ -1440,18 +1416,12 @@ export namespace Config {
} }
} }
const dep = yield* installDependencies(dir).pipe( const dep = iife(async () => {
Effect.exit, await installDependencies(dir)
Effect.tap((exit) => })
Exit.isFailure(exit) void dep.catch((err) => {
? Effect.sync(() => { log.warn("background dependency install failed", { dir, error: err })
log.warn("background dependency install failed", { dir, error: String(exit.cause) }) })
})
: Effect.void,
),
Effect.asVoid,
Effect.forkScoped,
)
deps.push(dep) deps.push(dep)
result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => loadCommand(dir))) result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => loadCommand(dir)))
@@ -1588,9 +1558,7 @@ export namespace Config {
}) })
const waitForDependencies = Effect.fn("Config.waitForDependencies")(function* () { const waitForDependencies = Effect.fn("Config.waitForDependencies")(function* () {
yield* InstanceState.useEffect(state, (s) => yield* InstanceState.useEffect(state, (s) => Effect.promise(() => Promise.all(s.deps).then(() => undefined)))
Effect.forEach(s.deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.asVoid),
)
}) })
const update = Effect.fn("Config.update")(function* (config: Info) { const update = Effect.fn("Config.update")(function* (config: Info) {
@@ -1645,7 +1613,6 @@ export namespace Config {
get, get,
getGlobal, getGlobal,
getConsoleState, getConsoleState,
installDependencies,
update, update,
updateGlobal, updateGlobal,
invalidate, invalidate,
@@ -1660,4 +1627,38 @@ export namespace Config {
Layer.provide(Auth.defaultLayer), Layer.provide(Auth.defaultLayer),
Layer.provide(Account.defaultLayer), Layer.provide(Account.defaultLayer),
) )
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function get() {
return runPromise((svc) => svc.get())
}
export async function getGlobal() {
return runPromise((svc) => svc.getGlobal())
}
export async function getConsoleState() {
return runPromise((svc) => svc.getConsoleState())
}
export async function update(config: Info) {
return runPromise((svc) => svc.update(config))
}
export async function updateGlobal(config: Info) {
return runPromise((svc) => svc.updateGlobal(config))
}
export async function invalidate(wait = false) {
return runPromise((svc) => svc.invalidate(wait))
}
export async function directories() {
return runPromise((svc) => svc.directories())
}
export async function waitForDependencies() {
return runPromise((svc) => svc.waitForDependencies())
}
} }
+1 -2
View File
@@ -10,7 +10,6 @@ import { Flag } from "@/flag/flag"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import { isRecord } from "@/util/record" import { isRecord } from "@/util/record"
import { Global } from "@/global" import { Global } from "@/global"
import { AppRuntime } from "@/effect/app-runtime"
export namespace TuiConfig { export namespace TuiConfig {
const log = Log.create({ service: "tui.config" }) const log = Log.create({ service: "tui.config" })
@@ -52,7 +51,7 @@ export namespace TuiConfig {
} }
function installDeps(dir: string): Promise<void> { function installDeps(dir: string): Promise<void> {
return AppRuntime.runPromise(Config.Service.use((cfg) => cfg.installDependencies(dir))) return Config.installDependencies(dir)
} }
async function mergeFile(acc: Acc, file: string) { async function mergeFile(acc: Acc, file: string) {
@@ -1,52 +1,20 @@
import { lazy } from "@/util/lazy" import { lazy } from "@/util/lazy"
import type { ProjectID } from "@/project/schema" import type { Adaptor } from "../types"
import type { WorkspaceAdaptor } from "../types"
export type WorkspaceAdaptorEntry = { const ADAPTORS: Record<string, () => Promise<Adaptor>> = {
type: string
name: string
description: string
}
const BUILTIN: Record<string, () => Promise<WorkspaceAdaptor>> = {
worktree: lazy(async () => (await import("./worktree")).WorktreeAdaptor), worktree: lazy(async () => (await import("./worktree")).WorktreeAdaptor),
} }
const state = new Map<ProjectID, Map<string, WorkspaceAdaptor>>() export function getAdaptor(type: string): Promise<Adaptor> {
return ADAPTORS[type]()
export async function getAdaptor(projectID: ProjectID, type: string): Promise<WorkspaceAdaptor> {
const custom = state.get(projectID)?.get(type)
if (custom) return custom
const builtin = BUILTIN[type]
if (builtin) return builtin()
throw new Error(`Unknown workspace adaptor: ${type}`)
} }
export async function listAdaptors(projectID: ProjectID): Promise<WorkspaceAdaptorEntry[]> { export function installAdaptor(type: string, adaptor: Adaptor) {
const builtin = await Promise.all( // This is experimental: mostly used for testing right now, but we
Object.entries(BUILTIN).map(async ([type, init]) => { // will likely allow this in the future. Need to figure out the
const adaptor = await init() // TypeScript story
return {
type,
name: adaptor.name,
description: adaptor.description,
}
}),
)
const custom = [...(state.get(projectID)?.entries() ?? [])].map(([type, adaptor]) => ({
type,
name: adaptor.name,
description: adaptor.description,
}))
return [...builtin, ...custom]
}
// Plugins can be loaded per-project so we need to scope them. If you // @ts-expect-error we force the builtin types right now, but we
// want to install a global one pass `ProjectID.global` // will implement a way to extend the types for custom adaptors
export function registerAdaptor(projectID: ProjectID, type: string, adaptor: WorkspaceAdaptor) { ADAPTORS[type] = () => adaptor
const adaptors = state.get(projectID) ?? new Map<string, WorkspaceAdaptor>()
adaptors.set(type, adaptor)
state.set(projectID, adaptors)
} }
@@ -1,18 +1,18 @@
import z from "zod" import z from "zod"
import { Worktree } from "@/worktree" import { Worktree } from "@/worktree"
import { type WorkspaceAdaptor, WorkspaceInfo } from "../types" import { type Adaptor, WorkspaceInfo } from "../types"
const WorktreeConfig = z.object({ const Config = WorkspaceInfo.extend({
name: WorkspaceInfo.shape.name, name: WorkspaceInfo.shape.name.unwrap(),
branch: WorkspaceInfo.shape.branch.unwrap(), branch: WorkspaceInfo.shape.branch.unwrap(),
directory: WorkspaceInfo.shape.directory.unwrap(), directory: WorkspaceInfo.shape.directory.unwrap(),
}) })
export const WorktreeAdaptor: WorkspaceAdaptor = { type Config = z.infer<typeof Config>
name: "Worktree",
description: "Create a git worktree", export const WorktreeAdaptor: Adaptor = {
async configure(info) { async configure(info) {
const worktree = await Worktree.makeWorktreeInfo(undefined) const worktree = await Worktree.makeWorktreeInfo(info.name ?? undefined)
return { return {
...info, ...info,
name: worktree.name, name: worktree.name,
@@ -21,7 +21,7 @@ export const WorktreeAdaptor: WorkspaceAdaptor = {
} }
}, },
async create(info) { async create(info) {
const config = WorktreeConfig.parse(info) const config = Config.parse(info)
await Worktree.createFromInfo({ await Worktree.createFromInfo({
name: config.name, name: config.name,
directory: config.directory, directory: config.directory,
@@ -29,11 +29,11 @@ export const WorktreeAdaptor: WorkspaceAdaptor = {
}) })
}, },
async remove(info) { async remove(info) {
const config = WorktreeConfig.parse(info) const config = Config.parse(info)
await Worktree.remove({ directory: config.directory }) await Worktree.remove({ directory: config.directory })
}, },
target(info) { target(info) {
const config = WorktreeConfig.parse(info) const config = Config.parse(info)
return { return {
type: "local", type: "local",
directory: config.directory, directory: config.directory,
+6 -8
View File
@@ -5,8 +5,8 @@ import { WorkspaceID } from "./schema"
export const WorkspaceInfo = z.object({ export const WorkspaceInfo = z.object({
id: WorkspaceID.zod, id: WorkspaceID.zod,
type: z.string(), type: z.string(),
name: z.string(),
branch: z.string().nullable(), branch: z.string().nullable(),
name: z.string().nullable(),
directory: z.string().nullable(), directory: z.string().nullable(),
extra: z.unknown().nullable(), extra: z.unknown().nullable(),
projectID: ProjectID.zod, projectID: ProjectID.zod,
@@ -24,11 +24,9 @@ export type Target =
headers?: HeadersInit headers?: HeadersInit
} }
export type WorkspaceAdaptor = { export type Adaptor = {
name: string configure(input: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
description: string create(config: WorkspaceInfo, from?: WorkspaceInfo): Promise<void>
configure(info: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo> remove(config: WorkspaceInfo): Promise<void>
create(info: WorkspaceInfo, from?: WorkspaceInfo): Promise<void> target(config: WorkspaceInfo): Target | Promise<Target>
remove(info: WorkspaceInfo): Promise<void>
target(info: WorkspaceInfo): Target | Promise<Target>
} }
@@ -6,8 +6,8 @@ import type { WorkspaceID } from "./schema"
export const WorkspaceTable = sqliteTable("workspace", { export const WorkspaceTable = sqliteTable("workspace", {
id: text().$type<WorkspaceID>().primaryKey(), id: text().$type<WorkspaceID>().primaryKey(),
type: text().notNull(), type: text().notNull(),
name: text().notNull().default(""),
branch: text(), branch: text(),
name: text(),
directory: text(), directory: text(),
extra: text({ mode: "json" }), extra: text({ mode: "json" }),
project_id: text() project_id: text()
@@ -9,7 +9,6 @@ import { SyncEvent } from "@/sync"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import { ProjectID } from "@/project/schema" import { ProjectID } from "@/project/schema"
import { Slug } from "@opencode-ai/util/slug"
import { WorkspaceTable } from "./workspace.sql" import { WorkspaceTable } from "./workspace.sql"
import { getAdaptor } from "./adaptors" import { getAdaptor } from "./adaptors"
import { WorkspaceInfo } from "./types" import { WorkspaceInfo } from "./types"
@@ -67,9 +66,9 @@ export namespace Workspace {
export const create = fn(CreateInput, async (input) => { export const create = fn(CreateInput, async (input) => {
const id = WorkspaceID.ascending(input.id) const id = WorkspaceID.ascending(input.id)
const adaptor = await getAdaptor(input.projectID, input.type) const adaptor = await getAdaptor(input.type)
const config = await adaptor.configure({ ...input, id, name: Slug.create(), directory: null }) const config = await adaptor.configure({ ...input, id, name: null, directory: null })
const info: Info = { const info: Info = {
id, id,
@@ -125,7 +124,7 @@ export namespace Workspace {
stopSync(id) stopSync(id)
const info = fromRow(row) const info = fromRow(row)
const adaptor = await getAdaptor(info.projectID, row.type) const adaptor = await getAdaptor(row.type)
adaptor.remove(info) adaptor.remove(info)
Database.use((db) => db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run()) Database.use((db) => db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run())
return info return info
@@ -163,7 +162,7 @@ export namespace Workspace {
log.info("connecting to sync: " + space.id) log.info("connecting to sync: " + space.id)
setStatus(space.id, "connecting") setStatus(space.id, "connecting")
const adaptor = await getAdaptor(space.projectID, space.type) const adaptor = await getAdaptor(space.type)
const target = await adaptor.target(space) const target = await adaptor.target(space)
if (target.type === "local") return if (target.type === "local") return
+2 -2
View File
@@ -49,7 +49,7 @@ import { ShareNext } from "@/share/share-next"
import { SessionShare } from "@/share/session" import { SessionShare } from "@/share/session"
export const AppLayer = Layer.mergeAll( export const AppLayer = Layer.mergeAll(
// Observability.layer, Observability.layer,
AppFileSystem.defaultLayer, AppFileSystem.defaultLayer,
Bus.defaultLayer, Bus.defaultLayer,
Auth.defaultLayer, Auth.defaultLayer,
@@ -95,6 +95,6 @@ export const AppLayer = Layer.mergeAll(
Installation.defaultLayer, Installation.defaultLayer,
ShareNext.defaultLayer, ShareNext.defaultLayer,
SessionShare.defaultLayer, SessionShare.defaultLayer,
).pipe(Layer.provide(Observability.layer)) )
export const AppRuntime = ManagedRuntime.make(AppLayer, { memoMap }) export const AppRuntime = ManagedRuntime.make(AppLayer, { memoMap })
@@ -1,27 +1,10 @@
import { Layer, ManagedRuntime } from "effect" import { Layer, ManagedRuntime } from "effect"
import { memoMap } from "./run-service" import { memoMap } from "./run-service"
import { Plugin } from "@/plugin"
import { LSP } from "@/lsp"
import { FileWatcher } from "@/file/watcher" import { FileWatcher } from "@/file/watcher"
import { Format } from "@/format" import { Format } from "@/format"
import { ShareNext } from "@/share/share-next" import { ShareNext } from "@/share/share-next"
import { File } from "@/file"
import { Vcs } from "@/project/vcs"
import { Snapshot } from "@/snapshot"
import { Bus } from "@/bus"
import { Observability } from "./oltp"
export const BootstrapLayer = Layer.mergeAll( export const BootstrapLayer = Layer.mergeAll(Format.defaultLayer, ShareNext.defaultLayer, FileWatcher.defaultLayer)
Plugin.defaultLayer,
ShareNext.defaultLayer,
Format.defaultLayer,
LSP.defaultLayer,
File.defaultLayer,
FileWatcher.defaultLayer,
Vcs.defaultLayer,
Snapshot.defaultLayer,
Bus.defaultLayer,
).pipe(Layer.provide(Observability.layer))
export const BootstrapRuntime = ManagedRuntime.make(BootstrapLayer, { memoMap }) export const BootstrapRuntime = ManagedRuntime.make(BootstrapLayer, { memoMap })
+23
View File
@@ -1,5 +1,6 @@
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem" import { AppFileSystem } from "@/filesystem"
import { Git } from "@/git" import { Git } from "@/git"
import { Effect, Layer, Context } from "effect" import { Effect, Layer, Context } from "effect"
@@ -643,4 +644,26 @@ export namespace File {
) )
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer)) export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export function init() {
return runPromise((svc) => svc.init())
}
export async function status() {
return runPromise((svc) => svc.status())
}
export async function read(file: string): Promise<Content> {
return runPromise((svc) => svc.read(file))
}
export async function list(dir?: string) {
return runPromise((svc) => svc.list(dir))
}
export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
return runPromise((svc) => svc.search(input))
}
} }
+54 -134
View File
@@ -3,7 +3,7 @@ import path from "path"
import { Global } from "../global" import { Global } from "../global"
import fs from "fs/promises" import fs from "fs/promises"
import z from "zod" import z from "zod"
import { Effect, Layer, Context, Schema } from "effect" import { Effect, Layer, Context } from "effect"
import * as Stream from "effect/Stream" import * as Stream from "effect/Stream"
import { ChildProcess } from "effect/unstable/process" import { ChildProcess } from "effect/unstable/process"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
@@ -94,43 +94,8 @@ export namespace Ripgrep {
const Result = z.union([Begin, Match, End, Summary]) const Result = z.union([Begin, Match, End, Summary])
const Hit = Schema.Struct({
type: Schema.Literal("match"),
data: Schema.Struct({
path: Schema.Struct({
text: Schema.String,
}),
lines: Schema.Struct({
text: Schema.String,
}),
line_number: Schema.Number,
absolute_offset: Schema.Number,
submatches: Schema.mutable(
Schema.Array(
Schema.Struct({
match: Schema.Struct({
text: Schema.String,
}),
start: Schema.Number,
end: Schema.Number,
}),
),
),
}),
})
const Row = Schema.Union([
Schema.Struct({ type: Schema.Literal("begin"), data: Schema.Unknown }),
Hit,
Schema.Struct({ type: Schema.Literal("end"), data: Schema.Unknown }),
Schema.Struct({ type: Schema.Literal("summary"), data: Schema.Unknown }),
])
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Row))
export type Result = z.infer<typeof Result> export type Result = z.infer<typeof Result>
export type Match = z.infer<typeof Match> export type Match = z.infer<typeof Match>
export type Item = Match["data"]
export type Begin = z.infer<typeof Begin> export type Begin = z.infer<typeof Begin>
export type End = z.infer<typeof End> export type End = z.infer<typeof End>
export type Summary = z.infer<typeof Summary> export type Summary = z.infer<typeof Summary>
@@ -324,13 +289,6 @@ export namespace Ripgrep {
follow?: boolean follow?: boolean
maxDepth?: number maxDepth?: number
}) => Stream.Stream<string, PlatformError> }) => Stream.Stream<string, PlatformError>
readonly search: (input: {
cwd: string
pattern: string
glob?: string[]
limit?: number
follow?: boolean
}) => Effect.Effect<{ items: Item[]; partial: boolean }, PlatformError | Error>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/Ripgrep") {} export class Service extends Context.Service<Service, Interface>()("@opencode/Ripgrep") {}
@@ -340,32 +298,6 @@ export namespace Ripgrep {
Effect.gen(function* () { Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner const spawner = yield* ChildProcessSpawner
const afs = yield* AppFileSystem.Service const afs = yield* AppFileSystem.Service
const bin = Effect.fn("Ripgrep.path")(function* () {
return yield* Effect.promise(() => filepath())
})
const args = Effect.fn("Ripgrep.args")(function* (input: {
mode: "files" | "search"
glob?: string[]
hidden?: boolean
follow?: boolean
maxDepth?: number
limit?: number
pattern?: string
}) {
const out = [yield* bin(), input.mode === "search" ? "--json" : "--files", "--glob=!.git/*"]
if (input.follow) out.push("--follow")
if (input.hidden !== false) out.push("--hidden")
if (input.maxDepth !== undefined) out.push(`--max-depth=${input.maxDepth}`)
if (input.glob) {
for (const g of input.glob) {
out.push(`--glob=${g}`)
}
}
if (input.limit) out.push(`--max-count=${input.limit}`)
if (input.mode === "search") out.push("--no-messages")
if (input.pattern) out.push("--", input.pattern)
return out
})
const files = Effect.fn("Ripgrep.files")(function* (input: { const files = Effect.fn("Ripgrep.files")(function* (input: {
cwd: string cwd: string
@@ -374,7 +306,7 @@ export namespace Ripgrep {
follow?: boolean follow?: boolean
maxDepth?: number maxDepth?: number
}) { }) {
const rgPath = yield* bin() const rgPath = yield* Effect.promise(() => filepath())
const isDir = yield* afs.isDir(input.cwd) const isDir = yield* afs.isDir(input.cwd)
if (!isDir) { if (!isDir) {
return yield* Effect.die( return yield* Effect.die(
@@ -386,77 +318,23 @@ export namespace Ripgrep {
) )
} }
const cmd = yield* args({ const args = [rgPath, "--files", "--glob=!.git/*"]
mode: "files", if (input.follow) args.push("--follow")
glob: input.glob, if (input.hidden !== false) args.push("--hidden")
hidden: input.hidden, if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`)
follow: input.follow, if (input.glob) {
maxDepth: input.maxDepth, for (const g of input.glob) {
}) args.push(`--glob=${g}`)
}
}
return spawner return spawner
.streamLines(ChildProcess.make(cmd[0], cmd.slice(1), { cwd: input.cwd })) .streamLines(ChildProcess.make(args[0], args.slice(1), { cwd: input.cwd }))
.pipe(Stream.filter((line: string) => line.length > 0)) .pipe(Stream.filter((line: string) => line.length > 0))
}) })
const search = Effect.fn("Ripgrep.search")(function* (input: {
cwd: string
pattern: string
glob?: string[]
limit?: number
follow?: boolean
}) {
return yield* Effect.scoped(
Effect.gen(function* () {
const cmd = yield* args({
mode: "search",
glob: input.glob,
follow: input.follow,
limit: input.limit,
pattern: input.pattern,
})
const handle = yield* spawner.spawn(
ChildProcess.make(cmd[0], cmd.slice(1), {
cwd: input.cwd,
stdin: "ignore",
}),
)
const [items, stderr, code] = yield* Effect.all(
[
Stream.decodeText(handle.stdout).pipe(
Stream.splitLines,
Stream.filter((line) => line.length > 0),
Stream.mapEffect((line) =>
decode(line).pipe(Effect.mapError((cause) => new Error("invalid ripgrep output", { cause }))),
),
Stream.filter((row): row is Schema.Schema.Type<typeof Hit> => row.type === "match"),
Stream.map((row): Item => row.data),
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
),
Stream.mkString(Stream.decodeText(handle.stderr)),
handle.exitCode,
],
{ concurrency: "unbounded" },
)
if (code !== 0 && code !== 1 && code !== 2) {
return yield* Effect.fail(new Error(`ripgrep failed: ${stderr}`))
}
return {
items,
partial: code === 2,
}
}),
)
})
return Service.of({ return Service.of({
files: (input) => Stream.unwrap(files(input)), files: (input) => Stream.unwrap(files(input)),
search,
}) })
}), }),
) )
@@ -523,4 +401,46 @@ export namespace Ripgrep {
return lines.join("\n") return lines.join("\n")
} }
export async function search(input: {
cwd: string
pattern: string
glob?: string[]
limit?: number
follow?: boolean
}) {
const args = [`${await filepath()}`, "--json", "--hidden", "--glob=!.git/*"]
if (input.follow) args.push("--follow")
if (input.glob) {
for (const g of input.glob) {
args.push(`--glob=${g}`)
}
}
if (input.limit) {
args.push(`--max-count=${input.limit}`)
}
args.push("--")
args.push(input.pattern)
const result = await Process.text(args, {
cwd: input.cwd,
nothrow: true,
})
if (result.code !== 0) {
return []
}
// Handle both Unix (\n) and Windows (\r\n) line endings
const lines = result.text.trim().split(/\r?\n/).filter(Boolean)
// Parse JSON lines from ripgrep output
return lines
.map((line) => JSON.parse(line))
.map((parsed) => Result.parse(parsed))
.filter((r) => r.type === "match")
.map((r) => r.data)
}
} }
+24 -12
View File
@@ -1,3 +1,4 @@
import { text } from "node:stream/consumers"
import { Npm } from "@/npm" import { Npm } from "@/npm"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Filesystem } from "../util/filesystem" import { Filesystem } from "../util/filesystem"
@@ -216,16 +217,26 @@ export const rlang: Info = {
name: "air", name: "air",
extensions: [".R"], extensions: [".R"],
async enabled() { async enabled() {
const air = which("air") const airPath = which("air")
if (air == null) return false if (airPath == null) return false
const output = await Process.text([air, "--help"], { nothrow: true }) try {
const proc = Process.spawn(["air", "--help"], {
stdout: "pipe",
stderr: "pipe",
})
await proc.exited
if (!proc.stdout) return false
const output = await text(proc.stdout)
// Check for "Air: An R language server and formatter" // Check for "Air: An R language server and formatter"
const firstLine = output.text.split("\n")[0] const firstLine = output.split("\n")[0]
const hasR = firstLine.includes("R language") const hasR = firstLine.includes("R language")
const hasFormatter = firstLine.includes("formatter") const hasFormatter = firstLine.includes("formatter")
if (output.code === 0 && hasR && hasFormatter) return [air, "format", "$FILE"] if (hasR && hasFormatter) return ["air", "format", "$FILE"]
} catch {
return false
}
return false return false
}, },
} }
@@ -235,10 +246,11 @@ export const uvformat: Info = {
extensions: [".py", ".pyi"], extensions: [".py", ".pyi"],
async enabled() { async enabled() {
if (await ruff.enabled()) return false if (await ruff.enabled()) return false
const uv = which("uv") if (which("uv") !== null) {
if (uv == null) return false const proc = Process.spawn(["uv", "format", "--help"], { stderr: "pipe", stdout: "pipe" })
const output = await Process.run([uv, "format", "--help"], { nothrow: true }) const code = await proc.exited
if (output.code === 0) return [uv, "format", "--", "$FILE"] if (code === 0) return ["uv", "format", "--", "$FILE"]
}
return false return false
}, },
} }
-1
View File
@@ -13,7 +13,6 @@ export namespace Identifier {
pty: "pty", pty: "pty",
tool: "tool", tool: "tool",
workspace: "wrk", workspace: "wrk",
entry: "ent",
} as const } as const
export function schema(prefix: keyof typeof prefixes) { export function schema(prefix: keyof typeof prefixes) {
+32
View File
@@ -13,6 +13,7 @@ import { Process } from "../util/process"
import { spawn as lspspawn } from "./launch" import { spawn as lspspawn } from "./launch"
import { Effect, Layer, Context } from "effect" import { Effect, Layer, Context } from "effect"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
export namespace LSP { export namespace LSP {
const log = Log.create({ service: "lsp" }) const log = Log.create({ service: "lsp" })
@@ -507,6 +508,37 @@ export namespace LSP {
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer)) export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export const init = async () => runPromise((svc) => svc.init())
export const status = async () => runPromise((svc) => svc.status())
export const hasClients = async (file: string) => runPromise((svc) => svc.hasClients(file))
export const touchFile = async (input: string, waitForDiagnostics?: boolean) =>
runPromise((svc) => svc.touchFile(input, waitForDiagnostics))
export const diagnostics = async () => runPromise((svc) => svc.diagnostics())
export const hover = async (input: LocInput) => runPromise((svc) => svc.hover(input))
export const definition = async (input: LocInput) => runPromise((svc) => svc.definition(input))
export const references = async (input: LocInput) => runPromise((svc) => svc.references(input))
export const implementation = async (input: LocInput) => runPromise((svc) => svc.implementation(input))
export const documentSymbol = async (uri: string) => runPromise((svc) => svc.documentSymbol(uri))
export const workspaceSymbol = async (query: string) => runPromise((svc) => svc.workspaceSymbol(query))
export const prepareCallHierarchy = async (input: LocInput) => runPromise((svc) => svc.prepareCallHierarchy(input))
export const incomingCalls = async (input: LocInput) => runPromise((svc) => svc.incomingCalls(input))
export const outgoingCalls = async (input: LocInput) => runPromise((svc) => svc.outgoingCalls(input))
export namespace Diagnostic { export namespace Diagnostic {
const MAX_PER_FILE = 20 const MAX_PER_FILE = 20
+34
View File
@@ -27,6 +27,7 @@ import open from "open"
import { Effect, Exit, Layer, Option, Context, Stream } from "effect" import { Effect, Exit, Layer, Option, Context, Stream } from "effect"
import { EffectLogger } from "@/effect/logger" import { EffectLogger } from "@/effect/logger"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
@@ -889,4 +890,37 @@ export namespace MCP {
Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer), Layer.provide(AppFileSystem.defaultLayer),
) )
const { runPromise } = makeRuntime(Service, defaultLayer)
// --- Async facade functions ---
export const status = async () => runPromise((svc) => svc.status())
export const tools = async () => runPromise((svc) => svc.tools())
export const prompts = async () => runPromise((svc) => svc.prompts())
export const resources = async () => runPromise((svc) => svc.resources())
export const add = async (name: string, mcp: Config.Mcp) => runPromise((svc) => svc.add(name, mcp))
export const connect = async (name: string) => runPromise((svc) => svc.connect(name))
export const disconnect = async (name: string) => runPromise((svc) => svc.disconnect(name))
export const startAuth = async (mcpName: string) => runPromise((svc) => svc.startAuth(mcpName))
export const authenticate = async (mcpName: string) => runPromise((svc) => svc.authenticate(mcpName))
export const finishAuth = async (mcpName: string, authorizationCode: string) =>
runPromise((svc) => svc.finishAuth(mcpName, authorizationCode))
export const removeAuth = async (mcpName: string) => runPromise((svc) => svc.removeAuth(mcpName))
export const supportsOAuth = async (mcpName: string) => runPromise((svc) => svc.supportsOAuth(mcpName))
export const hasStoredTokens = async (mcpName: string) => runPromise((svc) => svc.hasStoredTokens(mcpName))
export const getAuthStatus = async (mcpName: string) => runPromise((svc) => svc.getAuthStatus(mcpName))
} }
@@ -5,7 +5,6 @@ import { iife } from "@/util/iife"
import { Log } from "../../util/log" import { Log } from "../../util/log"
import { setTimeout as sleep } from "node:timers/promises" import { setTimeout as sleep } from "node:timers/promises"
import { CopilotModels } from "./models" import { CopilotModels } from "./models"
import { MessageV2 } from "@/session/message-v2"
const log = Log.create({ service: "plugin.copilot" }) const log = Log.create({ service: "plugin.copilot" })
@@ -28,21 +27,6 @@ function base(enterpriseUrl?: string) {
return enterpriseUrl ? `https://copilot-api.${normalizeDomain(enterpriseUrl)}` : "https://api.githubcopilot.com" return enterpriseUrl ? `https://copilot-api.${normalizeDomain(enterpriseUrl)}` : "https://api.githubcopilot.com"
} }
// Check if a message is a synthetic user msg used to attach an image from a tool call
function imgMsg(msg: any): boolean {
if (msg?.role !== "user") return false
// Handle the 3 api formats
const content = msg.content
if (typeof content === "string") return content === MessageV2.SYNTHETIC_ATTACHMENT_PROMPT
if (!Array.isArray(content)) return false
return content.some(
(part: any) =>
(part?.type === "text" || part?.type === "input_text") && part.text === MessageV2.SYNTHETIC_ATTACHMENT_PROMPT,
)
}
function fix(model: Model, url: string): Model { function fix(model: Model, url: string): Model {
return { return {
...model, ...model,
@@ -106,7 +90,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
(msg: any) => (msg: any) =>
Array.isArray(msg.content) && msg.content.some((part: any) => part.type === "image_url"), Array.isArray(msg.content) && msg.content.some((part: any) => part.type === "image_url"),
), ),
isAgent: last?.role !== "user" || imgMsg(last), isAgent: last?.role !== "user",
} }
} }
@@ -118,7 +102,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
(item: any) => (item: any) =>
Array.isArray(item?.content) && item.content.some((part: any) => part.type === "input_image"), Array.isArray(item?.content) && item.content.some((part: any) => part.type === "input_image"),
), ),
isAgent: last?.role !== "user" || imgMsg(last), isAgent: last?.role !== "user",
} }
} }
@@ -140,7 +124,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
part.content.some((nested: any) => nested?.type === "image")), part.content.some((nested: any) => nested?.type === "image")),
), ),
), ),
isAgent: !(last?.role === "user" && hasNonToolCalls) || imgMsg(last), isAgent: !(last?.role === "user" && hasNonToolCalls),
} }
} }
} catch {} } catch {}
+1 -14
View File
@@ -1,10 +1,4 @@
import type { import type { Hooks, PluginInput, Plugin as PluginInstance, PluginModule } from "@opencode-ai/plugin"
Hooks,
PluginInput,
Plugin as PluginInstance,
PluginModule,
WorkspaceAdaptor as PluginWorkspaceAdaptor,
} from "@opencode-ai/plugin"
import { Config } from "../config/config" import { Config } from "../config/config"
import { Bus } from "../bus" import { Bus } from "../bus"
import { Log } from "../util/log" import { Log } from "../util/log"
@@ -24,8 +18,6 @@ import { makeRuntime } from "@/effect/run-service"
import { errorMessage } from "@/util/error" import { errorMessage } from "@/util/error"
import { PluginLoader } from "./loader" import { PluginLoader } from "./loader"
import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared" import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared"
import { registerAdaptor } from "@/control-plane/adaptors"
import type { WorkspaceAdaptor } from "@/control-plane/types"
export namespace Plugin { export namespace Plugin {
const log = Log.create({ service: "plugin" }) const log = Log.create({ service: "plugin" })
@@ -140,11 +132,6 @@ export namespace Plugin {
project: ctx.project, project: ctx.project,
worktree: ctx.worktree, worktree: ctx.worktree,
directory: ctx.directory, directory: ctx.directory,
experimental_workspace: {
register(type: string, adaptor: PluginWorkspaceAdaptor) {
registerAdaptor(ctx.project.id, type, adaptor as WorkspaceAdaptor)
},
},
get serverUrl(): URL { get serverUrl(): URL {
return Server.url ?? new URL("http://localhost:4096") return Server.url ?? new URL("http://localhost:4096")
}, },
+16 -18
View File
@@ -9,26 +9,24 @@ import { Bus } from "../bus"
import { Command } from "../command" import { Command } from "../command"
import { Instance } from "./instance" import { Instance } from "./instance"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import { BootstrapRuntime } from "@/effect/bootstrap-runtime"
import { FileWatcher } from "@/file/watcher" import { FileWatcher } from "@/file/watcher"
import { ShareNext } from "@/share/share-next" import { ShareNext } from "@/share/share-next"
import * as Effect from "effect/Effect"
export const InstanceBootstrap = Effect.gen(function* () { export async function InstanceBootstrap() {
Log.Default.info("bootstrapping", { directory: Instance.directory }) Log.Default.info("bootstrapping", { directory: Instance.directory })
yield* Plugin.Service.use((svc) => svc.init()) await Plugin.init()
yield* ShareNext.Service.use((svc) => svc.init()).pipe(Effect.forkDetach) void BootstrapRuntime.runPromise(ShareNext.Service.use((svc) => svc.init()))
yield* Format.Service.use((svc) => svc.init()).pipe(Effect.forkDetach) void BootstrapRuntime.runPromise(Format.Service.use((svc) => svc.init()))
yield* LSP.Service.use((svc) => svc.init()) await LSP.init()
yield* File.Service.use((svc) => svc.init()).pipe(Effect.forkDetach) File.init()
yield* FileWatcher.Service.use((svc) => svc.init()).pipe(Effect.forkDetach) void BootstrapRuntime.runPromise(FileWatcher.Service.use((svc) => svc.init()))
yield* Vcs.Service.use((svc) => svc.init()).pipe(Effect.forkDetach) Vcs.init()
yield* Snapshot.Service.use((svc) => svc.init()).pipe(Effect.forkDetach) Snapshot.init()
yield* Bus.Service.use((svc) => Bus.subscribe(Command.Event.Executed, async (payload) => {
svc.subscribeCallback(Command.Event.Executed, async (payload) => { if (payload.properties.name === Command.Default.INIT) {
if (payload.properties.name === Command.Default.INIT) { Project.setInitialized(Instance.project.id)
Project.setInitialized(Instance.project.id) }
} })
}), }
)
}).pipe(Effect.withSpan("InstanceBootstrap"))
+19
View File
@@ -4,6 +4,7 @@ import path from "path"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem" import { AppFileSystem } from "@/filesystem"
import { FileWatcher } from "@/file/watcher" import { FileWatcher } from "@/file/watcher"
import { Git } from "@/git" import { Git } from "@/git"
@@ -230,4 +231,22 @@ export namespace Vcs {
Layer.provide(AppFileSystem.defaultLayer), Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Bus.layer), Layer.provide(Bus.layer),
) )
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function init() {
return runPromise((svc) => svc.init())
}
export async function branch() {
return runPromise((svc) => svc.branch())
}
export async function defaultBranch() {
return runPromise((svc) => svc.defaultBranch())
}
export async function diff(mode: Mode) {
return runPromise((svc) => svc.diff(mode))
}
} }
+2
View File
@@ -0,0 +1,2 @@
// Auto-generated by build.ts - do not edit
export declare const snapshot: Record<string, unknown>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -5
View File
@@ -209,9 +209,6 @@ export namespace ProviderTransform {
copilot: { copilot: {
copilot_cache_control: { type: "ephemeral" }, copilot_cache_control: { type: "ephemeral" },
}, },
alibaba: {
cacheControl: { type: "ephemeral" },
},
} }
for (const msg of unique([...system, ...final])) { for (const msg of unique([...system, ...final])) {
@@ -288,8 +285,7 @@ export namespace ProviderTransform {
model.api.id.includes("claude") || model.api.id.includes("claude") ||
model.id.includes("anthropic") || model.id.includes("anthropic") ||
model.id.includes("claude") || model.id.includes("claude") ||
model.api.npm === "@ai-sdk/anthropic" || model.api.npm === "@ai-sdk/anthropic") &&
model.api.npm === "@ai-sdk/alibaba") &&
model.api.npm !== "@ai-sdk/gateway" model.api.npm !== "@ai-sdk/gateway"
) { ) {
msgs = applyCaching(msgs, model) msgs = applyCaching(msgs, model)
+31
View File
@@ -1,6 +1,7 @@
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Instance } from "@/project/instance" import { Instance } from "@/project/instance"
import type { Proc } from "#pty" import type { Proc } from "#pty"
import z from "zod" import z from "zod"
@@ -360,4 +361,34 @@ export namespace Pty {
) )
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Plugin.defaultLayer)) export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Plugin.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function list() {
return runPromise((svc) => svc.list())
}
export async function get(id: PtyID) {
return runPromise((svc) => svc.get(id))
}
export async function write(id: PtyID, data: string) {
return runPromise((svc) => svc.write(id, data))
}
export async function connect(id: PtyID, ws: Socket, cursor?: number) {
return runPromise((svc) => svc.connect(id, ws, cursor))
}
export async function create(input: CreateInput) {
return runPromise((svc) => svc.create(input))
}
export async function update(id: PtyID, input: UpdateInput) {
return runPromise((svc) => svc.update(id, input))
}
export async function remove(id: PtyID) {
return runPromise((svc) => svc.remove(id))
}
} }
+2 -14
View File
@@ -1,7 +1,5 @@
import { Auth } from "@/auth" import { Auth } from "@/auth"
import { AppRuntime } from "@/effect/app-runtime"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import { Effect } from "effect"
import { ProviderID } from "@/provider/schema" import { ProviderID } from "@/provider/schema"
import { Hono } from "hono" import { Hono } from "hono"
import { describeRoute, resolver, validator, openAPIRouteHandler } from "hono-openapi" import { describeRoute, resolver, validator, openAPIRouteHandler } from "hono-openapi"
@@ -41,12 +39,7 @@ export function ControlPlaneRoutes(): Hono {
async (c) => { async (c) => {
const providerID = c.req.valid("param").providerID const providerID = c.req.valid("param").providerID
const info = c.req.valid("json") const info = c.req.valid("json")
await AppRuntime.runPromise( await Auth.set(providerID, info)
Effect.gen(function* () {
const auth = yield* Auth.Service
yield* auth.set(providerID, info)
}),
)
return c.json(true) return c.json(true)
}, },
) )
@@ -76,12 +69,7 @@ export function ControlPlaneRoutes(): Hono {
), ),
async (c) => { async (c) => {
const providerID = c.req.valid("param").providerID const providerID = c.req.valid("param").providerID
await AppRuntime.runPromise( await Auth.remove(providerID)
Effect.gen(function* () {
const auth = yield* Auth.Service
yield* auth.remove(providerID)
}),
)
return c.json(true) return c.json(true)
}, },
) )
@@ -7,8 +7,6 @@ import { mapValues } from "remeda"
import { errors } from "../error" import { errors } from "../error"
import { Log } from "../../util/log" import { Log } from "../../util/log"
import { lazy } from "../../util/lazy" import { lazy } from "../../util/lazy"
import { AppRuntime } from "../../effect/app-runtime"
import { Effect } from "effect"
const log = Log.create({ service: "server" }) const log = Log.create({ service: "server" })
@@ -32,7 +30,7 @@ export const ConfigRoutes = lazy(() =>
}, },
}), }),
async (c) => { async (c) => {
return c.json(await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))) return c.json(await Config.get())
}, },
) )
.patch( .patch(
@@ -56,7 +54,7 @@ export const ConfigRoutes = lazy(() =>
validator("json", Config.Info), validator("json", Config.Info),
async (c) => { async (c) => {
const config = c.req.valid("json") const config = c.req.valid("json")
await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.update(config))) await Config.update(config)
return c.json(config) return c.json(config)
}, },
) )
@@ -84,12 +82,7 @@ export const ConfigRoutes = lazy(() =>
}), }),
async (c) => { async (c) => {
using _ = log.time("providers") using _ = log.time("providers")
const providers = await AppRuntime.runPromise( const providers = await Provider.list().then((x) => mapValues(x, (item) => item))
Effect.gen(function* () {
const svc = yield* Provider.Service
return mapValues(yield* svc.list(), (item) => item)
}),
)
return c.json({ return c.json({
providers: Object.values(providers), providers: Object.values(providers),
default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id), default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
@@ -162,13 +162,7 @@ export const ExperimentalRoutes = lazy(() =>
}, },
}), }),
async (c) => { async (c) => {
const ids = await AppRuntime.runPromise( return c.json(await ToolRegistry.ids())
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
return yield* registry.ids()
}),
)
return c.json(ids)
}, },
) )
.get( .get(
@@ -211,17 +205,11 @@ export const ExperimentalRoutes = lazy(() =>
), ),
async (c) => { async (c) => {
const { provider, model } = c.req.valid("query") const { provider, model } = c.req.valid("query")
const tools = await AppRuntime.runPromise( const tools = await ToolRegistry.tools({
Effect.gen(function* () { providerID: ProviderID.make(provider),
const agents = yield* Agent.Service modelID: ModelID.make(model),
const registry = yield* ToolRegistry.Service agent: await Agent.get(await Agent.defaultAgent()),
return yield* registry.tools({ })
providerID: ProviderID.make(provider),
modelID: ModelID.make(model),
agent: yield* agents.get(yield* agents.defaultAgent()),
})
}),
)
return c.json( return c.json(
tools.map((t) => ({ tools.map((t) => ({
id: t.id, id: t.id,
@@ -408,14 +396,7 @@ export const ExperimentalRoutes = lazy(() =>
}, },
}), }),
async (c) => { async (c) => {
return c.json( return c.json(await MCP.resources())
await AppRuntime.runPromise(
Effect.gen(function* () {
const mcp = yield* MCP.Service
return yield* mcp.resources()
}),
),
)
}, },
), ),
) )
+20 -33
View File
@@ -1,8 +1,6 @@
import { Hono } from "hono" import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi" import { describeRoute, validator, resolver } from "hono-openapi"
import { Effect } from "effect"
import z from "zod" import z from "zod"
import { AppRuntime } from "../../effect/app-runtime"
import { File } from "../../file" import { File } from "../../file"
import { Ripgrep } from "../../file/ripgrep" import { Ripgrep } from "../../file/ripgrep"
import { LSP } from "../../lsp" import { LSP } from "../../lsp"
@@ -36,10 +34,12 @@ export const FileRoutes = lazy(() =>
), ),
async (c) => { async (c) => {
const pattern = c.req.valid("query").pattern const pattern = c.req.valid("query").pattern
const result = await AppRuntime.runPromise( const result = await Ripgrep.search({
Ripgrep.Service.use((svc) => svc.search({ cwd: Instance.directory, pattern, limit: 10 })), cwd: Instance.directory,
) pattern,
return c.json(result.items) limit: 10,
})
return c.json(result)
}, },
) )
.get( .get(
@@ -73,18 +73,12 @@ export const FileRoutes = lazy(() =>
const dirs = c.req.valid("query").dirs const dirs = c.req.valid("query").dirs
const type = c.req.valid("query").type const type = c.req.valid("query").type
const limit = c.req.valid("query").limit const limit = c.req.valid("query").limit
const results = await AppRuntime.runPromise( const results = await File.search({
Effect.gen(function* () { query,
return yield* File.Service.use((svc) => limit: limit ?? 10,
svc.search({ dirs: dirs !== "false",
query, type,
limit: limit ?? 10, })
dirs: dirs !== "false",
type,
}),
)
}),
)
return c.json(results) return c.json(results)
}, },
) )
@@ -112,6 +106,11 @@ export const FileRoutes = lazy(() =>
}), }),
), ),
async (c) => { async (c) => {
/*
const query = c.req.valid("query").query
const result = await LSP.workspaceSymbol(query)
return c.json(result)
*/
return c.json([]) return c.json([])
}, },
) )
@@ -140,11 +139,7 @@ export const FileRoutes = lazy(() =>
), ),
async (c) => { async (c) => {
const path = c.req.valid("query").path const path = c.req.valid("query").path
const content = await AppRuntime.runPromise( const content = await File.list(path)
Effect.gen(function* () {
return yield* File.Service.use((svc) => svc.list(path))
}),
)
return c.json(content) return c.json(content)
}, },
) )
@@ -173,11 +168,7 @@ export const FileRoutes = lazy(() =>
), ),
async (c) => { async (c) => {
const path = c.req.valid("query").path const path = c.req.valid("query").path
const content = await AppRuntime.runPromise( const content = await File.read(path)
Effect.gen(function* () {
return yield* File.Service.use((svc) => svc.read(path))
}),
)
return c.json(content) return c.json(content)
}, },
) )
@@ -199,11 +190,7 @@ export const FileRoutes = lazy(() =>
}, },
}), }),
async (c) => { async (c) => {
const content = await AppRuntime.runPromise( const content = await File.status()
Effect.gen(function* () {
return yield* File.Service.use((svc) => svc.status())
}),
)
return c.json(content) return c.json(content)
}, },
), ),
@@ -199,7 +199,7 @@ export const GlobalRoutes = lazy(() =>
}, },
}), }),
async (c) => { async (c) => {
return c.json(await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal()))) return c.json(await Config.getGlobal())
}, },
) )
.patch( .patch(
@@ -223,7 +223,7 @@ export const GlobalRoutes = lazy(() =>
validator("json", Config.Info), validator("json", Config.Info),
async (c) => { async (c) => {
const config = c.req.valid("json") const config = c.req.valid("json")
const next = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.updateGlobal(config))) const next = await Config.updateGlobal(config)
return c.json(next) return c.json(next)
}, },
) )
+8 -28
View File
@@ -1,7 +1,6 @@
import { describeRoute, resolver, validator } from "hono-openapi" import { describeRoute, resolver, validator } from "hono-openapi"
import { Hono } from "hono" import { Hono } from "hono"
import type { UpgradeWebSocket } from "hono/ws" import type { UpgradeWebSocket } from "hono/ws"
import { Effect } from "effect"
import z from "zod" import z from "zod"
import { Format } from "../../format" import { Format } from "../../format"
import { TuiRoutes } from "./tui" import { TuiRoutes } from "./tui"
@@ -120,17 +119,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
}, },
}), }),
async (c) => { async (c) => {
return c.json( const [branch, default_branch] = await Promise.all([Vcs.branch(), Vcs.defaultBranch()])
await AppRuntime.runPromise( return c.json({
Effect.gen(function* () { branch,
const vcs = yield* Vcs.Service default_branch,
const [branch, default_branch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], { })
concurrency: 2,
})
return { branch, default_branch }
}),
),
)
}, },
) )
.get( .get(
@@ -157,14 +150,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
}), }),
), ),
async (c) => { async (c) => {
return c.json( return c.json(await Vcs.diff(c.req.valid("query").mode))
await AppRuntime.runPromise(
Effect.gen(function* () {
const vcs = yield* Vcs.Service
return yield* vcs.diff(c.req.valid("query").mode)
}),
),
)
}, },
) )
.get( .get(
@@ -229,12 +215,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
}, },
}), }),
async (c) => { async (c) => {
const skills = await AppRuntime.runPromise( const skills = await Skill.all()
Effect.gen(function* () {
const skill = yield* Skill.Service
return yield* skill.all()
}),
)
return c.json(skills) return c.json(skills)
}, },
) )
@@ -256,8 +237,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
}, },
}), }),
async (c) => { async (c) => {
const items = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.status())) return c.json(await LSP.status())
return c.json(items)
}, },
) )
.get( .get(
+14 -34
View File
@@ -3,10 +3,8 @@ import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod" import z from "zod"
import { MCP } from "../../mcp" import { MCP } from "../../mcp"
import { Config } from "../../config/config" import { Config } from "../../config/config"
import { AppRuntime } from "../../effect/app-runtime"
import { errors } from "../error" import { errors } from "../error"
import { lazy } from "../../util/lazy" import { lazy } from "../../util/lazy"
import { Effect } from "effect"
export const McpRoutes = lazy(() => export const McpRoutes = lazy(() =>
new Hono() new Hono()
@@ -28,7 +26,7 @@ export const McpRoutes = lazy(() =>
}, },
}), }),
async (c) => { async (c) => {
return c.json(await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.status()))) return c.json(await MCP.status())
}, },
) )
.post( .post(
@@ -58,7 +56,7 @@ export const McpRoutes = lazy(() =>
), ),
async (c) => { async (c) => {
const { name, config } = c.req.valid("json") const { name, config } = c.req.valid("json")
const result = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.add(name, config))) const result = await MCP.add(name, config)
return c.json(result.status) return c.json(result.status)
}, },
) )
@@ -86,21 +84,12 @@ export const McpRoutes = lazy(() =>
}), }),
async (c) => { async (c) => {
const name = c.req.param("name") const name = c.req.param("name")
const result = await AppRuntime.runPromise( const supportsOAuth = await MCP.supportsOAuth(name)
Effect.gen(function* () { if (!supportsOAuth) {
const mcp = yield* MCP.Service
const supports = yield* mcp.supportsOAuth(name)
if (!supports) return { supports }
return {
supports,
auth: yield* mcp.startAuth(name),
}
}),
)
if (!result.supports) {
return c.json({ error: `MCP server ${name} does not support OAuth` }, 400) return c.json({ error: `MCP server ${name} does not support OAuth` }, 400)
} }
return c.json(result.auth) const result = await MCP.startAuth(name)
return c.json(result)
}, },
) )
.post( .post(
@@ -131,7 +120,7 @@ export const McpRoutes = lazy(() =>
async (c) => { async (c) => {
const name = c.req.param("name") const name = c.req.param("name")
const { code } = c.req.valid("json") const { code } = c.req.valid("json")
const status = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.finishAuth(name, code))) const status = await MCP.finishAuth(name, code)
return c.json(status) return c.json(status)
}, },
) )
@@ -155,21 +144,12 @@ export const McpRoutes = lazy(() =>
}), }),
async (c) => { async (c) => {
const name = c.req.param("name") const name = c.req.param("name")
const result = await AppRuntime.runPromise( const supportsOAuth = await MCP.supportsOAuth(name)
Effect.gen(function* () { if (!supportsOAuth) {
const mcp = yield* MCP.Service
const supports = yield* mcp.supportsOAuth(name)
if (!supports) return { supports }
return {
supports,
status: yield* mcp.authenticate(name),
}
}),
)
if (!result.supports) {
return c.json({ error: `MCP server ${name} does not support OAuth` }, 400) return c.json({ error: `MCP server ${name} does not support OAuth` }, 400)
} }
return c.json(result.status) const status = await MCP.authenticate(name)
return c.json(status)
}, },
) )
.delete( .delete(
@@ -192,7 +172,7 @@ export const McpRoutes = lazy(() =>
}), }),
async (c) => { async (c) => {
const name = c.req.param("name") const name = c.req.param("name")
await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.removeAuth(name))) await MCP.removeAuth(name)
return c.json({ success: true as const }) return c.json({ success: true as const })
}, },
) )
@@ -215,7 +195,7 @@ export const McpRoutes = lazy(() =>
validator("param", z.object({ name: z.string() })), validator("param", z.object({ name: z.string() })),
async (c) => { async (c) => {
const { name } = c.req.valid("param") const { name } = c.req.valid("param")
await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.connect(name))) await MCP.connect(name)
return c.json(true) return c.json(true)
}, },
) )
@@ -238,7 +218,7 @@ export const McpRoutes = lazy(() =>
validator("param", z.object({ name: z.string() })), validator("param", z.object({ name: z.string() })),
async (c) => { async (c) => {
const { name } = c.req.valid("param") const { name } = c.req.valid("param")
await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.disconnect(name))) await MCP.disconnect(name)
return c.json(true) return c.json(true)
}, },
), ),
@@ -10,7 +10,6 @@ import { InstanceBootstrap } from "@/project/bootstrap"
import { Session } from "@/session" import { Session } from "@/session"
import { SessionID } from "@/session/schema" import { SessionID } from "@/session/schema"
import { WorkspaceContext } from "@/control-plane/workspace-context" import { WorkspaceContext } from "@/control-plane/workspace-context"
import { AppRuntime } from "@/effect/app-runtime"
type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" } type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" }
@@ -67,7 +66,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
if (!workspaceID) { if (!workspaceID) {
return Instance.provide({ return Instance.provide({
directory, directory,
init: () => AppRuntime.runPromise(InstanceBootstrap), init: InstanceBootstrap,
async fn() { async fn() {
return next() return next()
}, },
@@ -95,7 +94,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
}) })
} }
const adaptor = await getAdaptor(workspace.projectID, workspace.type) const adaptor = await getAdaptor(workspace.type)
const target = await adaptor.target(workspace) const target = await adaptor.target(workspace)
if (target.type === "local") { if (target.type === "local") {
@@ -104,7 +103,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
fn: () => fn: () =>
Instance.provide({ Instance.provide({
directory: target.directory, directory: target.directory,
init: () => AppRuntime.runPromise(InstanceBootstrap), init: InstanceBootstrap,
async fn() { async fn() {
return next() return next()
}, },
@@ -8,7 +8,6 @@ import { ProjectID } from "../../project/schema"
import { errors } from "../error" import { errors } from "../error"
import { lazy } from "../../util/lazy" import { lazy } from "../../util/lazy"
import { InstanceBootstrap } from "../../project/bootstrap" import { InstanceBootstrap } from "../../project/bootstrap"
import { AppRuntime } from "@/effect/app-runtime"
export const ProjectRoutes = lazy(() => export const ProjectRoutes = lazy(() =>
new Hono() new Hono()
@@ -84,7 +83,7 @@ export const ProjectRoutes = lazy(() =>
directory: dir, directory: dir,
worktree: dir, worktree: dir,
project: next, project: next,
init: () => AppRuntime.runPromise(InstanceBootstrap), init: InstanceBootstrap,
}) })
return c.json(next) return c.json(next)
}, },
@@ -11,7 +11,6 @@ import { mapValues } from "remeda"
import { errors } from "../error" import { errors } from "../error"
import { lazy } from "../../util/lazy" import { lazy } from "../../util/lazy"
import { Log } from "../../util/log" import { Log } from "../../util/log"
import { Effect } from "effect"
const log = Log.create({ service: "server" }) const log = Log.create({ service: "server" })
@@ -41,36 +40,27 @@ export const ProviderRoutes = lazy(() =>
}, },
}), }),
async (c) => { async (c) => {
const result = await AppRuntime.runPromise( const config = await Config.get()
Effect.gen(function* () { const disabled = new Set(config.disabled_providers ?? [])
const svc = yield* Provider.Service const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
const cfg = yield* Config.Service
const config = yield* cfg.get() const allProviders = await ModelsDev.get()
const all = yield* Effect.promise(() => ModelsDev.get()) const filteredProviders: Record<string, (typeof allProviders)[string]> = {}
const disabled = new Set(config.disabled_providers ?? []) for (const [key, value] of Object.entries(allProviders)) {
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
const filtered: Record<string, (typeof all)[string]> = {} filteredProviders[key] = value
for (const [key, value] of Object.entries(all)) { }
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) { }
filtered[key] = value
} const connected = await Provider.list()
} const providers = Object.assign(
const connected = yield* svc.list() mapValues(filteredProviders, (x) => Provider.fromModelsDevProvider(x)),
const providers = Object.assign( connected,
mapValues(filtered, (x) => Provider.fromModelsDevProvider(x)),
connected,
)
return {
all: Object.values(providers),
default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
connected: Object.keys(connected),
}
}),
) )
return c.json({ return c.json({
all: result.all, all: Object.values(providers),
default: result.default, default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
connected: result.connected, connected: Object.keys(connected),
}) })
}, },
) )
+8 -56
View File
@@ -1,9 +1,7 @@
import { Hono, type MiddlewareHandler } from "hono" import { Hono, type MiddlewareHandler } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi" import { describeRoute, validator, resolver } from "hono-openapi"
import type { UpgradeWebSocket } from "hono/ws" import type { UpgradeWebSocket } from "hono/ws"
import { Effect } from "effect"
import z from "zod" import z from "zod"
import { AppRuntime } from "@/effect/app-runtime"
import { Pty } from "@/pty" import { Pty } from "@/pty"
import { PtyID } from "@/pty/schema" import { PtyID } from "@/pty/schema"
import { NotFoundError } from "../../storage/db" import { NotFoundError } from "../../storage/db"
@@ -29,14 +27,7 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
}, },
}), }),
async (c) => { async (c) => {
return c.json( return c.json(await Pty.list())
await AppRuntime.runPromise(
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.list()
}),
),
)
}, },
) )
.post( .post(
@@ -59,12 +50,7 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
}), }),
validator("json", Pty.CreateInput), validator("json", Pty.CreateInput),
async (c) => { async (c) => {
const info = await AppRuntime.runPromise( const info = await Pty.create(c.req.valid("json"))
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.create(c.req.valid("json"))
}),
)
return c.json(info) return c.json(info)
}, },
) )
@@ -88,12 +74,7 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
}), }),
validator("param", z.object({ ptyID: PtyID.zod })), validator("param", z.object({ ptyID: PtyID.zod })),
async (c) => { async (c) => {
const info = await AppRuntime.runPromise( const info = await Pty.get(c.req.valid("param").ptyID)
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.get(c.req.valid("param").ptyID)
}),
)
if (!info) { if (!info) {
throw new NotFoundError({ message: "Session not found" }) throw new NotFoundError({ message: "Session not found" })
} }
@@ -121,12 +102,7 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
validator("param", z.object({ ptyID: PtyID.zod })), validator("param", z.object({ ptyID: PtyID.zod })),
validator("json", Pty.UpdateInput), validator("json", Pty.UpdateInput),
async (c) => { async (c) => {
const info = await AppRuntime.runPromise( const info = await Pty.update(c.req.valid("param").ptyID, c.req.valid("json"))
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.update(c.req.valid("param").ptyID, c.req.valid("json"))
}),
)
return c.json(info) return c.json(info)
}, },
) )
@@ -150,12 +126,7 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
}), }),
validator("param", z.object({ ptyID: PtyID.zod })), validator("param", z.object({ ptyID: PtyID.zod })),
async (c) => { async (c) => {
await AppRuntime.runPromise( await Pty.remove(c.req.valid("param").ptyID)
Effect.gen(function* () {
const pty = yield* Pty.Service
yield* pty.remove(c.req.valid("param").ptyID)
}),
)
return c.json(true) return c.json(true)
}, },
) )
@@ -179,11 +150,6 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
}), }),
validator("param", z.object({ ptyID: PtyID.zod })), validator("param", z.object({ ptyID: PtyID.zod })),
upgradeWebSocket(async (c) => { upgradeWebSocket(async (c) => {
type Handler = {
onMessage: (message: string | ArrayBuffer) => void
onClose: () => void
}
const id = PtyID.zod.parse(c.req.param("ptyID")) const id = PtyID.zod.parse(c.req.param("ptyID"))
const cursor = (() => { const cursor = (() => {
const value = c.req.query("cursor") const value = c.req.query("cursor")
@@ -192,17 +158,8 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
if (!Number.isSafeInteger(parsed) || parsed < -1) return if (!Number.isSafeInteger(parsed) || parsed < -1) return
return parsed return parsed
})() })()
let handler: Handler | undefined let handler: Awaited<ReturnType<typeof Pty.connect>>
if ( if (!(await Pty.get(id))) throw new Error("Session not found")
!(await AppRuntime.runPromise(
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.get(id)
}),
))
) {
throw new Error("Session not found")
}
type Socket = { type Socket = {
readyState: number readyState: number
@@ -228,12 +185,7 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
ws.close() ws.close()
return return
} }
handler = await AppRuntime.runPromise( handler = await Pty.connect(id, socket, cursor)
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.connect(id, socket, cursor)
}),
)
ready = true ready = true
for (const msg of pending) handler?.onMessage(msg) for (const msg of pending) handler?.onMessage(msg)
pending.length = 0 pending.length = 0
@@ -1,41 +1,13 @@
import { Hono } from "hono" import { Hono } from "hono"
import { describeRoute, resolver, validator } from "hono-openapi" import { describeRoute, resolver, validator } from "hono-openapi"
import z from "zod" import z from "zod"
import { listAdaptors } from "../../control-plane/adaptors"
import { Workspace } from "../../control-plane/workspace" import { Workspace } from "../../control-plane/workspace"
import { Instance } from "../../project/instance" import { Instance } from "../../project/instance"
import { errors } from "../error" import { errors } from "../error"
import { lazy } from "../../util/lazy" import { lazy } from "../../util/lazy"
const WorkspaceAdaptor = z.object({
type: z.string(),
name: z.string(),
description: z.string(),
})
export const WorkspaceRoutes = lazy(() => export const WorkspaceRoutes = lazy(() =>
new Hono() new Hono()
.get(
"/adaptor",
describeRoute({
summary: "List workspace adaptors",
description: "List all available workspace adaptors for the current project.",
operationId: "experimental.workspace.adaptor.list",
responses: {
200: {
description: "Workspace adaptors",
content: {
"application/json": {
schema: resolver(z.array(WorkspaceAdaptor)),
},
},
},
},
}),
async (c) => {
return c.json(await listAdaptors(Instance.project.id))
},
)
.post( .post(
"/", "/",
describeRoute({ describeRoute({
+10 -10
View File
@@ -4,7 +4,7 @@ import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { Decimal } from "decimal.js" import { Decimal } from "decimal.js"
import z from "zod" import z from "zod"
import { type ProviderMetadata, type LanguageModelUsage } from "ai" import { type ProviderMetadata } from "ai"
import { Flag } from "../flag/flag" import { Flag } from "../flag/flag"
import { Installation } from "../installation" import { Installation } from "../installation"
@@ -28,6 +28,7 @@ import { SessionID, MessageID, PartID } from "./schema"
import type { Provider } from "@/provider/provider" import type { Provider } from "@/provider/provider"
import { Permission } from "@/permission" import { Permission } from "@/permission"
import { Global } from "@/global" import { Global } from "@/global"
import type { LanguageModelV2Usage } from "@ai-sdk/provider"
import { Effect, Layer, Option, Context } from "effect" import { Effect, Layer, Option, Context } from "effect"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
@@ -239,7 +240,7 @@ export namespace Session {
export const getUsage = (input: { export const getUsage = (input: {
model: Provider.Model model: Provider.Model
usage: LanguageModelUsage usage: LanguageModelV2Usage
metadata?: ProviderMetadata metadata?: ProviderMetadata
}) => { }) => {
const safe = (value: number) => { const safe = (value: number) => {
@@ -248,14 +249,11 @@ export namespace Session {
} }
const inputTokens = safe(input.usage.inputTokens ?? 0) const inputTokens = safe(input.usage.inputTokens ?? 0)
const outputTokens = safe(input.usage.outputTokens ?? 0) const outputTokens = safe(input.usage.outputTokens ?? 0)
const reasoningTokens = safe(input.usage.outputTokenDetails?.reasoningTokens ?? input.usage.reasoningTokens ?? 0) const reasoningTokens = safe(input.usage.reasoningTokens ?? 0)
const cacheReadInputTokens = safe( const cacheReadInputTokens = safe(input.usage.cachedInputTokens ?? 0)
input.usage.inputTokenDetails?.cacheReadTokens ?? input.usage.cachedInputTokens ?? 0,
)
const cacheWriteInputTokens = safe( const cacheWriteInputTokens = safe(
(input.usage.inputTokenDetails?.cacheWriteTokens ?? (input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
// google-vertex-anthropic returns metadata under "vertex" key // google-vertex-anthropic returns metadata under "vertex" key
// (AnthropicMessagesLanguageModel custom provider key from 'vertex.anthropic.messages') // (AnthropicMessagesLanguageModel custom provider key from 'vertex.anthropic.messages')
input.metadata?.["vertex"]?.["cacheCreationInputTokens"] ?? input.metadata?.["vertex"]?.["cacheCreationInputTokens"] ??
@@ -276,7 +274,7 @@ export namespace Session {
const tokens = { const tokens = {
total, total,
input: adjustedInputTokens, input: adjustedInputTokens,
output: safe(outputTokens - reasoningTokens), output: outputTokens - reasoningTokens,
reasoning: reasoningTokens, reasoning: reasoningTokens,
cache: { cache: {
write: cacheWriteInputTokens, write: cacheWriteInputTokens,
@@ -680,7 +678,9 @@ export namespace Session {
}), }),
) )
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Storage.defaultLayer)) export const defaultLayer = Layer.suspend(() =>
layer.pipe(Layer.provide(Bus.layer), Layer.provide(Storage.defaultLayer)),
)
const { runPromise } = makeRuntime(Service, defaultLayer) const { runPromise } = makeRuntime(Service, defaultLayer)
+8 -18
View File
@@ -94,24 +94,14 @@ export namespace LLM {
modelID: input.model.id, modelID: input.model.id,
providerID: input.model.providerID, providerID: input.model.providerID,
}) })
const [language, cfg, provider, info] = await Effect.runPromise( const [language, cfg, provider, auth] = await Promise.all([
Effect.gen(function* () { Provider.getLanguage(input.model),
const auth = yield* Auth.Service Config.get(),
const cfg = yield* Config.Service Provider.getProvider(input.model.providerID),
const provider = yield* Provider.Service Auth.get(input.model.providerID),
return yield* Effect.all( ])
[
provider.getLanguage(input.model),
cfg.get(),
provider.getProvider(input.model.providerID),
auth.get(input.model.providerID),
],
{ concurrency: "unbounded" },
)
}).pipe(Effect.provide(Layer.mergeAll(Auth.defaultLayer, Config.defaultLayer, Provider.defaultLayer))),
)
// TODO: move this to a proper hook // TODO: move this to a proper hook
const isOpenaiOauth = provider.id === "openai" && info?.type === "oauth" const isOpenaiOauth = provider.id === "openai" && auth?.type === "oauth"
const system: string[] = [] const system: string[] = []
system.push( system.push(
@@ -210,7 +200,7 @@ export namespace LLM {
}, },
) )
const tools = resolveTools(input) const tools = await resolveTools(input)
// LiteLLM and some Anthropic proxies require the tools parameter to be present // LiteLLM and some Anthropic proxies require the tools parameter to be present
// when message history contains tool calls, even if no tools are being used. // when message history contains tool calls, even if no tools are being used.
+1 -3
View File
@@ -25,8 +25,6 @@ interface FetchDecompressionError extends Error {
} }
export namespace MessageV2 { export namespace MessageV2 {
export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached image(s) from tool result:"
export function isMedia(mime: string) { export function isMedia(mime: string) {
return mime.startsWith("image/") || mime === "application/pdf" return mime.startsWith("image/") || mime === "application/pdf"
} }
@@ -810,7 +808,7 @@ export namespace MessageV2 {
parts: [ parts: [
{ {
type: "text" as const, type: "text" as const,
text: SYNTHETIC_ATTACHMENT_PROMPT, text: "Attached image(s) from tool result:",
}, },
...media.map((attachment) => ({ ...media.map((attachment) => ({
type: "file" as const, type: "file" as const,
+1 -31
View File
@@ -1,11 +1,10 @@
import { NotFoundError, eq, and, sql } from "../storage/db" import { NotFoundError, eq, and } from "../storage/db"
import { SyncEvent } from "@/sync" import { SyncEvent } from "@/sync"
import { Session } from "./index" import { Session } from "./index"
import { MessageV2 } from "./message-v2" import { MessageV2 } from "./message-v2"
import { SessionTable, MessageTable, PartTable } from "./session.sql" import { SessionTable, MessageTable, PartTable } from "./session.sql"
import { ProjectTable } from "../project/project.sql" import { ProjectTable } from "../project/project.sql"
import { Log } from "../util/log" import { Log } from "../util/log"
import { DateTime } from "effect"
const log = Log.create({ service: "session.projector" }) const log = Log.create({ service: "session.projector" })
@@ -133,33 +132,4 @@ export default [
log.warn("ignored late part update", { partID: id, messageID, sessionID }) log.warn("ignored late part update", { partID: id, messageID, sessionID })
} }
}), }),
// Experimental
SyncEvent.project(MessageV2.Event.PartUpdated, (db, data) => {
/*
const id = SessionEntry.ID.make(data.part.id.replace("prt", "ent"))
switch (data.part.type) {
case "text":
db.insert(SessionEntryTable)
.values({
id,
session_id: data.sessionID,
type: "text",
data: new SessionEntry.Text({
id,
text: data.part.text,
type: "text",
time: {
created: DateTime.makeUnsafe(data.part.time?.start ?? Date.now()),
completed: data.part.time?.end ? DateTime.makeUnsafe(data.part.time.end) : undefined,
},
}),
time_created: Date.now(),
time_updated: Date.now(),
})
.onConflictDoUpdate({ target: SessionEntryTable.id, set: { data: sql`excluded.data` } })
.run()
}
*/
}),
] ]
@@ -1,7 +1,6 @@
import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlite-core" import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlite-core"
import { ProjectTable } from "../project/project.sql" import { ProjectTable } from "../project/project.sql"
import type { MessageV2 } from "./message-v2" import type { MessageV2 } from "./message-v2"
import type { SessionEntry } from "../v2/session-entry"
import type { Snapshot } from "../snapshot" import type { Snapshot } from "../snapshot"
import type { Permission } from "../permission" import type { Permission } from "../permission"
import type { ProjectID } from "../project/schema" import type { ProjectID } from "../project/schema"
@@ -11,7 +10,6 @@ import { Timestamps } from "../storage/schema.sql"
type PartData = Omit<MessageV2.Part, "id" | "sessionID" | "messageID"> type PartData = Omit<MessageV2.Part, "id" | "sessionID" | "messageID">
type InfoData = Omit<MessageV2.Info, "id" | "sessionID"> type InfoData = Omit<MessageV2.Info, "id" | "sessionID">
type EntryData = Omit<SessionEntry.Entry, "id" | "type">
export const SessionTable = sqliteTable( export const SessionTable = sqliteTable(
"session", "session",
@@ -96,27 +94,6 @@ export const TodoTable = sqliteTable(
], ],
) )
/*
export const SessionEntryTable = sqliteTable(
"session_entry",
{
id: text().$type<SessionEntry.ID>().primaryKey(),
session_id: text()
.$type<SessionID>()
.notNull()
.references(() => SessionTable.id, { onDelete: "cascade" }),
type: text().notNull(),
...Timestamps,
data: text({ mode: "json" }).notNull().$type<SessionEntry.Entry>(),
},
(table) => [
index("session_entry_session_idx").on(table.session_id),
index("session_entry_session_type_idx").on(table.session_id, table.type),
index("session_entry_time_created_idx").on(table.time_created),
],
)
*/
export const PermissionTable = sqliteTable("permission", { export const PermissionTable = sqliteTable("permission", {
project_id: text() project_id: text()
.primaryKey() .primaryKey()
+9 -7
View File
@@ -339,12 +339,14 @@ export namespace ShareNext {
}), }),
) )
export const defaultLayer = layer.pipe( export const defaultLayer = Layer.suspend(() =>
Layer.provide(Bus.layer), layer.pipe(
Layer.provide(Account.defaultLayer), Layer.provide(Bus.layer),
Layer.provide(Config.defaultLayer), Layer.provide(Account.defaultLayer),
Layer.provide(FetchHttpClient.layer), Layer.provide(Config.defaultLayer),
Layer.provide(Provider.defaultLayer), Layer.provide(FetchHttpClient.layer),
Layer.provide(Session.defaultLayer), Layer.provide(Provider.defaultLayer),
Layer.provide(Session.defaultLayer),
),
) )
} }
+26 -5
View File
@@ -7,6 +7,7 @@ import { NamedError } from "@opencode-ai/util/error"
import type { Agent } from "@/agent/agent" import type { Agent } from "@/agent/agent"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { Global } from "@/global" import { Global } from "@/global"
import { Permission } from "@/permission" import { Permission } from "@/permission"
@@ -229,11 +230,13 @@ export namespace Skill {
}), }),
) )
export const defaultLayer = layer.pipe( export const defaultLayer = Layer.suspend(() =>
Layer.provide(Discovery.defaultLayer), layer.pipe(
Layer.provide(Config.defaultLayer), Layer.provide(Discovery.defaultLayer),
Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Bus.layer),
Layer.provide(AppFileSystem.defaultLayer),
),
) )
export function fmt(list: Info[], opts: { verbose: boolean }) { export function fmt(list: Info[], opts: { verbose: boolean }) {
@@ -261,4 +264,22 @@ export namespace Skill {
.map((skill) => `- **${skill.name}**: ${skill.description}`), .map((skill) => `- **${skill.name}**: ${skill.description}`),
].join("\n") ].join("\n")
} }
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function get(name: string) {
return runPromise((skill) => skill.get(name))
}
export async function all() {
return runPromise((skill) => skill.all())
}
export async function dirs() {
return runPromise((skill) => skill.dirs())
}
export async function available(agent?: Agent.Info) {
return runPromise((skill) => skill.available(agent))
}
} }
+7 -87
View File
@@ -177,39 +177,8 @@ export namespace Snapshot {
const all = Array.from(new Set([...tracked, ...untracked])) const all = Array.from(new Set([...tracked, ...untracked]))
if (!all.length) return if (!all.length) return
// Filter out files that are now gitignored even if previously tracked
// Files may have been tracked before being gitignored, so we need to check
// against the source project's current gitignore rules
// Use --no-index to check purely against patterns (ignoring whether file is tracked)
const checkArgs = [
...quote,
"--git-dir",
path.join(state.worktree, ".git"),
"--work-tree",
state.worktree,
"check-ignore",
"--no-index",
"--",
...all,
]
const check = yield* git(checkArgs, { cwd: state.directory })
const ignored =
check.code === 0 ? new Set(check.text.trim().split("\n").filter(Boolean)) : new Set<string>()
const filtered = all.filter((item) => !ignored.has(item))
// Remove newly-ignored files from snapshot index to prevent re-adding
if (ignored.size > 0) {
const ignoredFiles = Array.from(ignored)
log.info("removing gitignored files from snapshot", { count: ignoredFiles.length })
yield* git([...cfg, ...args(["rm", "--cached", "-f", "--", ...ignoredFiles])], {
cwd: state.directory,
})
}
if (!filtered.length) return
const large = (yield* Effect.all( const large = (yield* Effect.all(
filtered.map((item) => all.map((item) =>
fs fs
.stat(path.join(state.directory, item)) .stat(path.join(state.directory, item))
.pipe(Effect.catch(() => Effect.void)) .pipe(Effect.catch(() => Effect.void))
@@ -290,39 +259,14 @@ export namespace Snapshot {
log.warn("failed to get diff", { hash, exitCode: result.code }) log.warn("failed to get diff", { hash, exitCode: result.code })
return { hash, files: [] } return { hash, files: [] }
} }
const files = result.text
.trim()
.split("\n")
.map((x) => x.trim())
.filter(Boolean)
// Filter out files that are now gitignored
if (files.length > 0) {
const checkArgs = [
...quote,
"--git-dir",
path.join(state.worktree, ".git"),
"--work-tree",
state.worktree,
"check-ignore",
"--no-index",
"--",
...files,
]
const check = yield* git(checkArgs, { cwd: state.directory })
if (check.code === 0) {
const ignored = new Set(check.text.trim().split("\n").filter(Boolean))
const filtered = files.filter((item) => !ignored.has(item))
return {
hash,
files: filtered.map((x) => path.join(state.worktree, x).replaceAll("\\", "/")),
}
}
}
return { return {
hash, hash,
files: files.map((x) => path.join(state.worktree, x).replaceAll("\\", "/")), files: result.text
.trim()
.split("\n")
.map((x) => x.trim())
.filter(Boolean)
.map((x) => path.join(state.worktree, x).replaceAll("\\", "/")),
} }
}), }),
) )
@@ -672,30 +616,6 @@ export namespace Snapshot {
} satisfies Row, } satisfies Row,
] ]
}) })
// Filter out files that are now gitignored
if (rows.length > 0) {
const files = rows.map((r) => r.file)
const checkArgs = [
...quote,
"--git-dir",
path.join(state.worktree, ".git"),
"--work-tree",
state.worktree,
"check-ignore",
"--no-index",
"--",
...files,
]
const check = yield* git(checkArgs, { cwd: state.directory })
if (check.code === 0) {
const ignored = new Set(check.text.trim().split("\n").filter(Boolean))
const filtered = rows.filter((r) => !ignored.has(r.file))
rows.length = 0
rows.push(...filtered)
}
}
const step = 100 const step = 100
const patch = (file: string, before: string, after: string) => const patch = (file: string, before: string, after: string) =>
formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER })) formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER }))
+86 -53
View File
@@ -1,8 +1,11 @@
import z from "zod" import z from "zod"
import { Effect, Option } from "effect" import { Effect } from "effect"
import * as Stream from "effect/Stream"
import { Tool } from "./tool" import { Tool } from "./tool"
import { Filesystem } from "../util/filesystem"
import { Ripgrep } from "../file/ripgrep" import { Ripgrep } from "../file/ripgrep"
import { AppFileSystem } from "../filesystem" import { ChildProcess } from "effect/unstable/process"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import DESCRIPTION from "./grep.txt" import DESCRIPTION from "./grep.txt"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
@@ -14,8 +17,7 @@ const MAX_LINE_LENGTH = 2000
export const GrepTool = Tool.define( export const GrepTool = Tool.define(
"grep", "grep",
Effect.gen(function* () { Effect.gen(function* () {
const fs = yield* AppFileSystem.Service const spawner = yield* ChildProcessSpawner
const rg = yield* Ripgrep.Service
return { return {
description: DESCRIPTION, description: DESCRIPTION,
@@ -26,11 +28,6 @@ export const GrepTool = Tool.define(
}), }),
execute: (params: { pattern: string; path?: string; include?: string }, ctx: Tool.Context) => execute: (params: { pattern: string; path?: string; include?: string }, ctx: Tool.Context) =>
Effect.gen(function* () { Effect.gen(function* () {
const empty = {
title: params.pattern,
metadata: { matches: 0, truncated: false },
output: "No files found",
}
if (!params.pattern) { if (!params.pattern) {
throw new Error("pattern is required") throw new Error("pattern is required")
} }
@@ -46,58 +43,92 @@ export const GrepTool = Tool.define(
}, },
}) })
const searchPath = AppFileSystem.resolve( let searchPath = params.path ?? Instance.directory
path.isAbsolute(params.path ?? Instance.directory) searchPath = path.isAbsolute(searchPath) ? searchPath : path.resolve(Instance.directory, searchPath)
? (params.path ?? Instance.directory)
: path.join(Instance.directory, params.path ?? "."),
)
yield* assertExternalDirectoryEffect(ctx, searchPath, { kind: "directory" }) yield* assertExternalDirectoryEffect(ctx, searchPath, { kind: "directory" })
const result = yield* rg.search({ const rgPath = yield* Effect.promise(() => Ripgrep.filepath())
cwd: searchPath, const args = ["-nH", "--hidden", "--no-messages", "--field-match-separator=|", "--regexp", params.pattern]
pattern: params.pattern, if (params.include) {
glob: params.include ? [params.include] : undefined, args.push("--glob", params.include)
}) }
args.push(searchPath)
if (result.items.length === 0) return empty const result = yield* Effect.scoped(
Effect.gen(function* () {
const handle = yield* spawner.spawn(
ChildProcess.make(rgPath, args, {
stdin: "ignore",
}),
)
const rows = result.items.map((item) => ({ const [output, errorOutput] = yield* Effect.all(
path: AppFileSystem.resolve( [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
path.isAbsolute(item.path.text) ? item.path.text : path.join(searchPath, item.path.text), { concurrency: 2 },
), )
line: item.line_number,
text: item.lines.text, const exitCode = yield* handle.exitCode
}))
const times = new Map( return { output, errorOutput, exitCode }
(yield* Effect.forEach( }),
[...new Set(rows.map((row) => row.path))],
Effect.fnUntraced(function* (file) {
const info = yield* fs.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!info || info.type === "Directory") return undefined
return [
file,
info.mtime.pipe(
Option.map((time) => time.getTime()),
Option.getOrElse(() => 0),
) ?? 0,
] as const
}),
{ concurrency: 16 },
)).filter((entry): entry is readonly [string, number] => Boolean(entry)),
) )
const matches = rows.flatMap((row) => {
const mtime = times.get(row.path)
if (mtime === undefined) return []
return [{ ...row, mtime }]
})
matches.sort((a, b) => b.mtime - a.mtime) const { output, errorOutput, exitCode } = result
// Exit codes: 0 = matches found, 1 = no matches, 2 = errors (but may still have matches)
// With --no-messages, we suppress error output but still get exit code 2 for broken symlinks etc.
// Only fail if exit code is 2 AND no output was produced
if (exitCode === 1 || (exitCode === 2 && !output.trim())) {
return {
title: params.pattern,
metadata: { matches: 0, truncated: false },
output: "No files found",
}
}
if (exitCode !== 0 && exitCode !== 2) {
throw new Error(`ripgrep failed: ${errorOutput}`)
}
const hasErrors = exitCode === 2
// Handle both Unix (\n) and Windows (\r\n) line endings
const lines = output.trim().split(/\r?\n/)
const matches = []
for (const line of lines) {
if (!line) continue
const [filePath, lineNumStr, ...lineTextParts] = line.split("|")
if (!filePath || !lineNumStr || lineTextParts.length === 0) continue
const lineNum = parseInt(lineNumStr, 10)
const lineText = lineTextParts.join("|")
const stats = Filesystem.stat(filePath)
if (!stats) continue
matches.push({
path: filePath,
modTime: stats.mtime.getTime(),
lineNum,
lineText,
})
}
matches.sort((a, b) => b.modTime - a.modTime)
const limit = 100 const limit = 100
const truncated = matches.length > limit const truncated = matches.length > limit
const finalMatches = truncated ? matches.slice(0, limit) : matches const finalMatches = truncated ? matches.slice(0, limit) : matches
if (finalMatches.length === 0) return empty if (finalMatches.length === 0) {
return {
title: params.pattern,
metadata: { matches: 0, truncated: false },
output: "No files found",
}
}
const totalMatches = matches.length const totalMatches = matches.length
const outputLines = [`Found ${totalMatches} matches${truncated ? ` (showing first ${limit})` : ""}`] const outputLines = [`Found ${totalMatches} matches${truncated ? ` (showing first ${limit})` : ""}`]
@@ -112,8 +143,10 @@ export const GrepTool = Tool.define(
outputLines.push(`${match.path}:`) outputLines.push(`${match.path}:`)
} }
const truncatedLineText = const truncatedLineText =
match.text.length > MAX_LINE_LENGTH ? match.text.substring(0, MAX_LINE_LENGTH) + "..." : match.text match.lineText.length > MAX_LINE_LENGTH
outputLines.push(` Line ${match.line}: ${truncatedLineText}`) ? match.lineText.substring(0, MAX_LINE_LENGTH) + "..."
: match.lineText
outputLines.push(` Line ${match.lineNum}: ${truncatedLineText}`)
} }
if (truncated) { if (truncated) {
@@ -123,7 +156,7 @@ export const GrepTool = Tool.define(
) )
} }
if (result.partial) { if (hasErrors) {
outputLines.push("") outputLines.push("")
outputLines.push("(Some paths were inaccessible and skipped)") outputLines.push("(Some paths were inaccessible and skipped)")
} }
+15
View File
@@ -36,6 +36,7 @@ import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { Ripgrep } from "../file/ripgrep" import { Ripgrep } from "../file/ripgrep"
import { Format } from "../format" import { Format } from "../format"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Env } from "../env" import { Env } from "../env"
import { Question } from "../question" import { Question } from "../question"
import { Todo } from "../session/todo" import { Todo } from "../session/todo"
@@ -343,4 +344,18 @@ export namespace ToolRegistry {
Layer.provide(Truncate.defaultLayer), Layer.provide(Truncate.defaultLayer),
), ),
) )
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function ids() {
return runPromise((svc) => svc.ids())
}
export async function tools(input: {
providerID: ProviderID
modelID: ModelID
agent: Agent.Info
}): Promise<(Tool.Def & { id: string })[]> {
return runPromise((svc) => svc.tools(input))
}
} }
+68
View File
@@ -0,0 +1,68 @@
import { Identifier } from "@/id/id"
import { withStatics } from "@/util/schema"
import { DateTime, Effect, Schema } from "effect"
export namespace Message {
export const ID = Schema.String.pipe(Schema.brand("Message.ID")).pipe(
withStatics((s) => ({
create: () => s.make(Identifier.ascending("message")),
prefix: "msg",
})),
)
export class File extends Schema.Class<File>("Message.File")({
url: Schema.String,
mime: Schema.String,
}) {
static create(url: string) {
return new File({
url,
mime: "text/plain",
})
}
}
export class UserContent extends Schema.Class<UserContent>("Message.User.Content")({
text: Schema.String,
synthetic: Schema.Boolean.pipe(Schema.optional),
agent: Schema.String.pipe(Schema.optional),
files: Schema.Array(File).pipe(Schema.optional),
}) {}
export class User extends Schema.Class<User>("Message.User")({
id: ID,
type: Schema.Literal("user"),
time: Schema.Struct({
created: Schema.DateTimeUtc,
}),
content: UserContent,
}) {
static create(content: Schema.Schema.Type<typeof UserContent>) {
const msg = new User({
id: ID.create(),
type: "user",
time: {
created: Effect.runSync(DateTime.now),
},
content,
})
return msg
}
static file(url: string) {
return new File({
url,
mime: "text/plain",
})
}
}
export namespace User {}
}
const msg = Message.User.create({
text: "Hello world",
files: [Message.File.create("file://example.com/file.txt")],
})
console.log(JSON.stringify(msg, null, 2))
-186
View File
@@ -1,186 +0,0 @@
import { Identifier } from "@/id/id"
import { withStatics } from "@/util/schema"
import { DateTime, Effect, Schema } from "effect"
export namespace SessionEntry {
export const ID = Schema.String.pipe(Schema.brand("Session.Entry.ID")).pipe(
withStatics((s) => ({
create: () => s.make(Identifier.ascending("entry")),
prefix: "ent",
})),
)
export type ID = Schema.Schema.Type<typeof ID>
const Base = {
id: ID,
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
time: Schema.Struct({
created: Schema.DateTimeUtc,
}),
}
export class Source extends Schema.Class<Source>("Session.Entry.Source")({
start: Schema.Number,
end: Schema.Number,
text: Schema.String,
}) {}
export class FileAttachment extends Schema.Class<FileAttachment>("Session.Entry.File.Attachment")({
uri: Schema.String,
mime: Schema.String,
name: Schema.String.pipe(Schema.optional),
description: Schema.String.pipe(Schema.optional),
source: Source.pipe(Schema.optional),
}) {
static create(url: string) {
return new FileAttachment({
uri: url,
mime: "text/plain",
})
}
}
export class AgentAttachment extends Schema.Class<AgentAttachment>("Session.Entry.Agent.Attachment")({
name: Schema.String,
source: Source.pipe(Schema.optional),
}) {}
export class User extends Schema.Class<User>("Session.Entry.User")({
...Base,
type: Schema.Literal("user"),
text: Schema.String,
files: Schema.Array(FileAttachment).pipe(Schema.optional),
agents: Schema.Array(AgentAttachment).pipe(Schema.optional),
}) {
static create(input: { text: User["text"]; files?: User["files"]; agents?: User["agents"] }) {
const msg = new User({
id: ID.create(),
type: "user",
...input,
time: {
created: Effect.runSync(DateTime.now),
},
})
return msg
}
}
export class Synthetic extends Schema.Class<Synthetic>("Session.Entry.Synthetic")({
...Base,
type: Schema.Literal("synthetic"),
text: Schema.String,
}) {}
export class Request extends Schema.Class<Request>("Session.Entry.Request")({
...Base,
type: Schema.Literal("start"),
model: Schema.Struct({
id: Schema.String,
providerID: Schema.String,
variant: Schema.String.pipe(Schema.optional),
}),
}) {}
export class Text extends Schema.Class<Text>("Session.Entry.Text")({
...Base,
type: Schema.Literal("text"),
text: Schema.String,
time: Schema.Struct({
...Base.time.fields,
completed: Schema.DateTimeUtc.pipe(Schema.optional),
}),
}) {}
export class Reasoning extends Schema.Class<Reasoning>("Session.Entry.Reasoning")({
...Base,
type: Schema.Literal("reasoning"),
text: Schema.String,
time: Schema.Struct({
...Base.time.fields,
completed: Schema.DateTimeUtc.pipe(Schema.optional),
}),
}) {}
export class ToolStatePending extends Schema.Class<ToolStatePending>("Session.Entry.ToolState.Pending")({
status: Schema.Literal("pending"),
input: Schema.Record(Schema.String, Schema.Unknown),
raw: Schema.String,
}) {}
export class ToolStateRunning extends Schema.Class<ToolStateRunning>("Session.Entry.ToolState.Running")({
status: Schema.Literal("running"),
input: Schema.Record(Schema.String, Schema.Unknown),
title: Schema.String.pipe(Schema.optional),
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
}) {}
export class ToolStateCompleted extends Schema.Class<ToolStateCompleted>("Session.Entry.ToolState.Completed")({
status: Schema.Literal("completed"),
input: Schema.Record(Schema.String, Schema.Unknown),
output: Schema.String,
title: Schema.String,
metadata: Schema.Record(Schema.String, Schema.Unknown),
attachments: Schema.Array(FileAttachment).pipe(Schema.optional),
}) {}
export class ToolStateError extends Schema.Class<ToolStateError>("Session.Entry.ToolState.Error")({
status: Schema.Literal("error"),
input: Schema.Record(Schema.String, Schema.Unknown),
error: Schema.String,
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
time: Schema.Struct({
start: Schema.Number,
end: Schema.Number,
}),
}) {}
export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError])
export type ToolState = Schema.Schema.Type<typeof ToolState>
export class Tool extends Schema.Class<Tool>("Session.Entry.Tool")({
...Base,
type: Schema.Literal("tool"),
callID: Schema.String,
name: Schema.String,
state: ToolState,
time: Schema.Struct({
...Base.time.fields,
ran: Schema.DateTimeUtc.pipe(Schema.optional),
completed: Schema.DateTimeUtc.pipe(Schema.optional),
pruned: Schema.DateTimeUtc.pipe(Schema.optional),
}),
}) {}
export class Complete extends Schema.Class<Complete>("Session.Entry.Complete")({
...Base,
type: Schema.Literal("complete"),
cost: Schema.Number,
reason: Schema.String,
tokens: Schema.Struct({
input: Schema.Number,
output: Schema.Number,
reasoning: Schema.Number,
cache: Schema.Struct({
read: Schema.Number,
write: Schema.Number,
}),
}),
}) {}
export class Retry extends Schema.Class<Retry>("Session.Entry.Retry")({
...Base,
type: Schema.Literal("retry"),
attempt: Schema.Number,
error: Schema.String,
}) {}
export class Compaction extends Schema.Class<Compaction>("Session.Entry.Compaction")({
...Base,
type: Schema.Literal("compaction"),
auto: Schema.Boolean,
overflow: Schema.Boolean.pipe(Schema.optional),
}) {}
export const Entry = Schema.Union([User, Synthetic, Request, Tool, Text, Reasoning, Complete, Retry, Compaction])
export type Entry = Schema.Schema.Type<typeof Entry>
}
-71
View File
@@ -1,71 +0,0 @@
import { Context, Layer, Schema, Effect } from "effect"
import { SessionEntry } from "./session-entry"
import { Struct } from "effect"
import { Identifier } from "@/id/id"
import { withStatics } from "@/util/schema"
import { Session } from "@/session"
import { SessionID } from "@/session/schema"
export namespace SessionV2 {
export const ID = SessionID
export type ID = Schema.Schema.Type<typeof ID>
export class PromptInput extends Schema.Class<PromptInput>("Session.PromptInput")({
...Struct.omit(SessionEntry.User.fields, ["time", "type"]),
id: Schema.optionalKey(SessionEntry.ID),
sessionID: SessionV2.ID,
}) {}
export class CreateInput extends Schema.Class<CreateInput>("Session.CreateInput")({
id: Schema.optionalKey(SessionV2.ID),
}) {}
export class Info extends Schema.Class<Info>("Session.Info")({
id: SessionV2.ID,
model: Schema.Struct({
id: Schema.String,
providerID: Schema.String,
modelID: Schema.String,
}).pipe(Schema.optional),
}) {}
export interface Interface {
fromID: (id: SessionV2.ID) => Effect.Effect<Info>
create: (input: CreateInput) => Effect.Effect<Info>
prompt: (input: PromptInput) => Effect.Effect<SessionEntry.User>
}
export class Service extends Context.Service<Service, Interface>()("Session.Service") {}
export const layer = Layer.effect(Service)(
Effect.gen(function* () {
const session = yield* Session.Service
const create: Interface["create"] = Effect.fn("Session.create")(function* (input) {
throw new Error("Not implemented")
})
const prompt: Interface["prompt"] = Effect.fn("Session.prompt")(function* (input) {
throw new Error("Not implemented")
})
const fromID: Interface["fromID"] = Effect.fn("Session.fromID")(function* (id) {
const match = yield* session.get(id)
return fromV1(match)
})
return Service.of({
create,
prompt,
fromID,
})
}),
)
function fromV1(input: Session.Info): Info {
return new Info({
id: SessionV2.ID.make(input.id),
})
}
}
+1 -2
View File
@@ -17,7 +17,6 @@ import { Effect, Layer, Path, Scope, Context, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { NodePath } from "@effect/platform-node" import { NodePath } from "@effect/platform-node"
import { AppFileSystem } from "@/filesystem" import { AppFileSystem } from "@/filesystem"
import { BootstrapRuntime } from "@/effect/bootstrap-runtime"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
@@ -267,7 +266,7 @@ export namespace Worktree {
const booted = yield* Effect.promise(() => const booted = yield* Effect.promise(() =>
Instance.provide({ Instance.provide({
directory: info.directory, directory: info.directory,
init: () => BootstrapRuntime.runPromise(InstanceBootstrap), init: InstanceBootstrap,
fn: () => undefined, fn: () => undefined,
}) })
.then(() => true) .then(() => true)
+52 -80
View File
@@ -1,86 +1,58 @@
import { describe, expect } from "bun:test" import { test, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Auth } from "../../src/auth" import { Auth } from "../../src/auth"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const node = CrossSpawnSpawner.defaultLayer test("set normalizes trailing slashes in keys", async () => {
await Auth.set("https://example.com/", {
type: "wellknown",
key: "TOKEN",
token: "abc",
})
const data = await Auth.all()
expect(data["https://example.com"]).toBeDefined()
expect(data["https://example.com/"]).toBeUndefined()
})
const it = testEffect(Layer.mergeAll(Auth.defaultLayer, node)) test("set cleans up pre-existing trailing-slash entry", async () => {
// Simulate a pre-fix entry with trailing slash
await Auth.set("https://example.com/", {
type: "wellknown",
key: "TOKEN",
token: "old",
})
// Re-login with normalized key (as the CLI does post-fix)
await Auth.set("https://example.com", {
type: "wellknown",
key: "TOKEN",
token: "new",
})
const data = await Auth.all()
const keys = Object.keys(data).filter((k) => k.includes("example.com"))
expect(keys).toEqual(["https://example.com"])
const entry = data["https://example.com"]!
expect(entry.type).toBe("wellknown")
if (entry.type === "wellknown") expect(entry.token).toBe("new")
})
describe("Auth", () => { test("remove deletes both trailing-slash and normalized keys", async () => {
it.live("set normalizes trailing slashes in keys", () => await Auth.set("https://example.com", {
provideTmpdirInstance(() => type: "wellknown",
Effect.gen(function* () { key: "TOKEN",
const auth = yield* Auth.Service token: "abc",
yield* auth.set("https://example.com/", { })
type: "wellknown", await Auth.remove("https://example.com/")
key: "TOKEN", const data = await Auth.all()
token: "abc", expect(data["https://example.com"]).toBeUndefined()
}) expect(data["https://example.com/"]).toBeUndefined()
const data = yield* auth.all() })
expect(data["https://example.com"]).toBeDefined()
expect(data["https://example.com/"]).toBeUndefined()
}),
),
)
it.live("set cleans up pre-existing trailing-slash entry", () => test("set and remove are no-ops on keys without trailing slashes", async () => {
provideTmpdirInstance(() => await Auth.set("anthropic", {
Effect.gen(function* () { type: "api",
const auth = yield* Auth.Service key: "sk-test",
yield* auth.set("https://example.com/", { })
type: "wellknown", const data = await Auth.all()
key: "TOKEN", expect(data["anthropic"]).toBeDefined()
token: "old", await Auth.remove("anthropic")
}) const after = await Auth.all()
yield* auth.set("https://example.com", { expect(after["anthropic"]).toBeUndefined()
type: "wellknown",
key: "TOKEN",
token: "new",
})
const data = yield* auth.all()
const keys = Object.keys(data).filter((key) => key.includes("example.com"))
expect(keys).toEqual(["https://example.com"])
const entry = data["https://example.com"]!
expect(entry.type).toBe("wellknown")
if (entry.type === "wellknown") expect(entry.token).toBe("new")
}),
),
)
it.live("remove deletes both trailing-slash and normalized keys", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const auth = yield* Auth.Service
yield* auth.set("https://example.com", {
type: "wellknown",
key: "TOKEN",
token: "abc",
})
yield* auth.remove("https://example.com/")
const data = yield* auth.all()
expect(data["https://example.com"]).toBeUndefined()
expect(data["https://example.com/"]).toBeUndefined()
}),
),
)
it.live("set and remove are no-ops on keys without trailing slashes", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const auth = yield* Auth.Service
yield* auth.set("anthropic", {
type: "api",
key: "sk-test",
})
const data = yield* auth.all()
expect(data["anthropic"]).toBeDefined()
yield* auth.remove("anthropic")
const after = yield* auth.all()
expect(after["anthropic"]).toBeUndefined()
}),
),
)
}) })
@@ -1,10 +1,10 @@
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Deferred, Effect, Layer, Stream } from "effect" import { Deferred, Effect, Layer, Stream } from "effect"
import z from "zod" import z from "zod"
import { Bus } from "../../src/bus" import { Bus } from "../../src/bus"
import { BusEvent } from "../../src/bus/bus-event" import { BusEvent } from "../../src/bus/bus-event"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
@@ -13,7 +13,9 @@ const TestEvent = {
Pong: BusEvent.define("test.effect.pong", z.object({ message: z.string() })), Pong: BusEvent.define("test.effect.pong", z.object({ message: z.string() })),
} }
const node = CrossSpawnSpawner.defaultLayer const node = NodeChildProcessSpawner.layer.pipe(
Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)),
)
const live = Layer.mergeAll(Bus.layer, node) const live = Layer.mergeAll(Bus.layer, node)
@@ -6,6 +6,7 @@ import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin" import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { Global } from "../../../src/global" import { Global } from "../../../src/global"
import { TuiConfig } from "../../../src/config/tui" import { TuiConfig } from "../../../src/config/tui"
import { Config } from "../../../src/config/config"
import { Filesystem } from "../../../src/util/filesystem" import { Filesystem } from "../../../src/util/filesystem"
const { allThemes, addTheme } = await import("../../../src/cli/cmd/tui/context/theme") const { allThemes, addTheme } = await import("../../../src/cli/cmd/tui/context/theme")
@@ -324,6 +325,7 @@ export default {
}) })
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const install = spyOn(Config, "installDependencies").mockResolvedValue()
try { try {
expect(addTheme(tmp.extra.preloadedThemeName, { theme: { primary: "#303030" } })).toBe(true) expect(addTheme(tmp.extra.preloadedThemeName, { theme: { primary: "#303030" } })).toBe(true)
@@ -405,6 +407,7 @@ export default {
await TuiPluginRuntime.dispose() await TuiPluginRuntime.dispose()
cwd.mockRestore() cwd.mockRestore()
wait.mockRestore() wait.mockRestore()
install.mockRestore()
if (backup === undefined) { if (backup === undefined) {
await fs.rm(globalConfigPath, { force: true }) await fs.rm(globalConfigPath, { force: true })
} else { } else {
@@ -698,6 +701,7 @@ test("updates installed theme when plugin metadata changes", async () => {
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const install = spyOn(Config, "installDependencies").mockResolvedValue()
const api = () => const api = () =>
createTuiPluginApi({ createTuiPluginApi({
@@ -742,6 +746,7 @@ test("updates installed theme when plugin metadata changes", async () => {
await TuiPluginRuntime.dispose() await TuiPluginRuntime.dispose()
cwd.mockRestore() cwd.mockRestore()
wait.mockRestore() wait.mockRestore()
install.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE delete process.env.OPENCODE_PLUGIN_META_FILE
} }
}) })
@@ -5,9 +5,6 @@ import { Instance } from "../../src/project/instance"
import { Config } from "../../src/config/config" import { Config } from "../../src/config/config"
import { Agent as AgentSvc } from "../../src/agent/agent" import { Agent as AgentSvc } from "../../src/agent/agent"
import { Color } from "../../src/util/color" import { Color } from "../../src/util/color"
import { AppRuntime } from "../../src/effect/app-runtime"
const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get()))
test("agent color parsed from project config", async () => { test("agent color parsed from project config", async () => {
await using tmp = await tmpdir({ await using tmp = await tmpdir({
@@ -27,7 +24,7 @@ test("agent color parsed from project config", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const cfg = await load() const cfg = await Config.get()
expect(cfg.agent?.["build"]?.color).toBe("#FFA500") expect(cfg.agent?.["build"]?.color).toBe("#FFA500")
expect(cfg.agent?.["plan"]?.color).toBe("primary") expect(cfg.agent?.["plan"]?.color).toBe("primary")
}, },
+174 -203
View File
@@ -1,5 +1,5 @@
import { test, expect, describe, mock, afterEach, beforeEach, spyOn } from "bun:test" import { test, expect, describe, mock, afterEach, beforeEach, spyOn } from "bun:test"
import { Deferred, Effect, Fiber, Layer, Option } from "effect" import { Effect, Layer, Option } from "effect"
import { NodeFileSystem, NodePath } from "@effect/platform-node" import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Config } from "../../src/config/config" import { Config } from "../../src/config/config"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
@@ -7,9 +7,8 @@ import { Auth } from "../../src/auth"
import { AccessToken, Account, AccountID, OrgID } from "../../src/account" import { AccessToken, Account, AccountID, OrgID } from "../../src/account"
import { AppFileSystem } from "../../src/filesystem" import { AppFileSystem } from "../../src/filesystem"
import { provideTmpdirInstance } from "../fixture/fixture" import { provideTmpdirInstance } from "../fixture/fixture"
import { tmpdir, tmpdirScoped } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { testEffect } from "../lib/effect"
/** Infra layer that provides FileSystem, Path, ChildProcessSpawner for test fixtures */ /** Infra layer that provides FileSystem, Path, ChildProcessSpawner for test fixtures */
const infra = CrossSpawnSpawner.defaultLayer.pipe( const infra = CrossSpawnSpawner.defaultLayer.pipe(
@@ -33,38 +32,16 @@ const emptyAuth = Layer.mock(Auth.Service)({
all: () => Effect.succeed({}), all: () => Effect.succeed({}),
}) })
const layer = Config.layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(emptyAuth),
Layer.provide(emptyAccount),
Layer.provideMerge(infra),
)
const it = testEffect(layer)
const load = () => Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(layer)))
const save = (config: Config.Info) =>
Effect.runPromise(Config.Service.use((svc) => svc.update(config)).pipe(Effect.scoped, Effect.provide(layer)))
const clear = (wait = false) =>
Effect.runPromise(Config.Service.use((svc) => svc.invalidate(wait)).pipe(Effect.scoped, Effect.provide(layer)))
const listDirs = () =>
Effect.runPromise(Config.Service.use((svc) => svc.directories()).pipe(Effect.scoped, Effect.provide(layer)))
const ready = () =>
Effect.runPromise(Config.Service.use((svc) => svc.waitForDependencies()).pipe(Effect.scoped, Effect.provide(layer)))
const installDeps = (dir: string, input?: Config.InstallInput) =>
Config.Service.use((svc) => svc.installDependencies(dir, input))
// Get managed config directory from environment (set in preload.ts) // Get managed config directory from environment (set in preload.ts)
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR! const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
beforeEach(async () => { beforeEach(async () => {
await clear(true) await Config.invalidate(true)
}) })
afterEach(async () => { afterEach(async () => {
await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {}) await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {})
await clear(true) await Config.invalidate(true)
}) })
async function writeManagedSettings(settings: object, filename = "opencode.json") { async function writeManagedSettings(settings: object, filename = "opencode.json") {
@@ -82,7 +59,7 @@ async function check(map: (dir: string) => string) {
await using tmp = await tmpdir({ git: true, config: { snapshot: true } }) await using tmp = await tmpdir({ git: true, config: { snapshot: true } })
const prev = Global.Path.config const prev = Global.Path.config
;(Global.Path as { config: string }).config = globalTmp.path ;(Global.Path as { config: string }).config = globalTmp.path
await clear() await Config.invalidate()
try { try {
await writeConfig(globalTmp.path, { await writeConfig(globalTmp.path, {
$schema: "https://opencode.ai/config.json", $schema: "https://opencode.ai/config.json",
@@ -91,7 +68,7 @@ async function check(map: (dir: string) => string) {
await Instance.provide({ await Instance.provide({
directory: map(tmp.path), directory: map(tmp.path),
fn: async () => { fn: async () => {
const cfg = await load() const cfg = await Config.get()
expect(cfg.snapshot).toBe(true) expect(cfg.snapshot).toBe(true)
expect(Instance.directory).toBe(Filesystem.resolve(tmp.path)) expect(Instance.directory).toBe(Filesystem.resolve(tmp.path))
expect(Instance.project.id).not.toBe(ProjectID.global) expect(Instance.project.id).not.toBe(ProjectID.global)
@@ -100,7 +77,7 @@ async function check(map: (dir: string) => string) {
} finally { } finally {
await Instance.disposeAll() await Instance.disposeAll()
;(Global.Path as { config: string }).config = prev ;(Global.Path as { config: string }).config = prev
await clear() await Config.invalidate()
} }
} }
@@ -109,7 +86,7 @@ test("loads config with defaults when no files exist", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await load() const config = await Config.get()
expect(config.username).toBeDefined() expect(config.username).toBeDefined()
}, },
}) })
@@ -128,7 +105,7 @@ test("loads JSON config file", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await load() const config = await Config.get()
expect(config.model).toBe("test/model") expect(config.model).toBe("test/model")
expect(config.username).toBe("testuser") expect(config.username).toBe("testuser")
}, },
@@ -166,7 +143,7 @@ test("ignores legacy tui keys in opencode config", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await load() const config = await Config.get()
expect(config.model).toBe("test/model") expect(config.model).toBe("test/model")
expect((config as Record<string, unknown>).theme).toBeUndefined() expect((config as Record<string, unknown>).theme).toBeUndefined()
expect((config as Record<string, unknown>).tui).toBeUndefined() expect((config as Record<string, unknown>).tui).toBeUndefined()
@@ -191,7 +168,7 @@ test("loads JSONC config file", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await load() const config = await Config.get()
expect(config.model).toBe("test/model") expect(config.model).toBe("test/model")
expect(config.username).toBe("testuser") expect(config.username).toBe("testuser")
}, },
@@ -219,7 +196,7 @@ test("jsonc overrides json in the same directory", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await load() const config = await Config.get()
expect(config.model).toBe("base") expect(config.model).toBe("base")
expect(config.username).toBe("base") expect(config.username).toBe("base")
}, },
@@ -242,7 +219,7 @@ test("handles environment variable substitution", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await load() const config = await Config.get()
expect(config.username).toBe("test-user") expect(config.username).toBe("test-user")
}, },
}) })
@@ -274,7 +251,7 @@ test("preserves env variables when adding $schema to config", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await load() const config = await Config.get()
expect(config.username).toBe("secret_value") expect(config.username).toBe("secret_value")
// Read the file to verify the env variable was preserved // Read the file to verify the env variable was preserved
@@ -368,7 +345,7 @@ test("handles file inclusion substitution", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await load() const config = await Config.get()
expect(config.username).toBe("test-user") expect(config.username).toBe("test-user")
}, },
}) })
@@ -387,7 +364,7 @@ test("handles file inclusion with replacement tokens", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await load() const config = await Config.get()
expect(config.username).toBe("const out = await Bun.$`echo hi`") expect(config.username).toBe("const out = await Bun.$`echo hi`")
}, },
}) })
@@ -406,7 +383,7 @@ test("validates config schema and throws on invalid fields", async () => {
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
// Strict schema should throw an error for invalid fields // Strict schema should throw an error for invalid fields
await expect(load()).rejects.toThrow() await expect(Config.get()).rejects.toThrow()
}, },
}) })
}) })
@@ -420,7 +397,7 @@ test("throws error for invalid JSON", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await expect(load()).rejects.toThrow() await expect(Config.get()).rejects.toThrow()
}, },
}) })
}) })
@@ -443,7 +420,7 @@ test("handles agent configuration", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await load() const config = await Config.get()
expect(config.agent?.["test_agent"]).toEqual( expect(config.agent?.["test_agent"]).toEqual(
expect.objectContaining({ expect.objectContaining({
model: "test/model", model: "test/model",
@@ -474,7 +451,7 @@ test("treats agent variant as model-scoped setting (not provider option)", async
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await load() const config = await Config.get()
const agent = config.agent?.["test_agent"] const agent = config.agent?.["test_agent"]
expect(agent?.variant).toBe("xhigh") expect(agent?.variant).toBe("xhigh")
@@ -504,7 +481,7 @@ test("handles command configuration", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await load() const config = await Config.get()
expect(config.command?.["test_command"]).toEqual({ expect(config.command?.["test_command"]).toEqual({
template: "test template", template: "test template",
description: "test command", description: "test command",
@@ -529,7 +506,7 @@ test("migrates autoshare to share field", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await load() const config = await Config.get()
expect(config.share).toBe("auto") expect(config.share).toBe("auto")
expect(config.autoshare).toBe(true) expect(config.autoshare).toBe(true)
}, },
@@ -556,7 +533,7 @@ test("migrates mode field to agent field", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await load() const config = await Config.get()
expect(config.agent?.["test_mode"]).toEqual({ expect(config.agent?.["test_mode"]).toEqual({
model: "test/model", model: "test/model",
temperature: 0.5, temperature: 0.5,
@@ -588,7 +565,7 @@ Test agent prompt`,
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await load() const config = await Config.get()
expect(config.agent?.["test"]).toEqual( expect(config.agent?.["test"]).toEqual(
expect.objectContaining({ expect.objectContaining({
name: "test", name: "test",
@@ -632,7 +609,7 @@ Nested agent prompt`,
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await load() const config = await Config.get()
expect(config.agent?.["helper"]).toMatchObject({ expect(config.agent?.["helper"]).toMatchObject({
name: "helper", name: "helper",
@@ -681,7 +658,7 @@ Nested command template`,
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await load() const config = await Config.get()
expect(config.command?.["hello"]).toEqual({ expect(config.command?.["hello"]).toEqual({
description: "Test command", description: "Test command",
@@ -726,7 +703,7 @@ Nested command template`,
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await load() const config = await Config.get()
expect(config.command?.["hello"]).toEqual({ expect(config.command?.["hello"]).toEqual({
description: "Test command", description: "Test command",
@@ -747,7 +724,7 @@ test("updates config and writes to file", async () => {
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const newConfig = { model: "updated/model" } const newConfig = { model: "updated/model" }
await save(newConfig as any) await Config.update(newConfig as any)
const writtenConfig = await Filesystem.readJson(path.join(tmp.path, "config.json")) const writtenConfig = await Filesystem.readJson(path.join(tmp.path, "config.json"))
expect(writtenConfig.model).toBe("updated/model") expect(writtenConfig.model).toBe("updated/model")
@@ -760,7 +737,7 @@ test("gets config directories", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const dirs = await listDirs() const dirs = await Config.directories()
expect(dirs.length).toBeGreaterThanOrEqual(1) expect(dirs.length).toBeGreaterThanOrEqual(1)
}, },
}) })
@@ -790,7 +767,7 @@ test("does not try to install dependencies in read-only OPENCODE_CONFIG_DIR", as
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await load() await Config.get()
}, },
}) })
} finally { } finally {
@@ -824,8 +801,8 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await load() await Config.get()
await ready() await Config.waitForDependencies()
}, },
}) })
@@ -840,134 +817,128 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
} }
}) })
it.live("dedupes concurrent config dependency installs for the same dir", () => test("dedupes concurrent config dependency installs for the same dir", async () => {
Effect.gen(function* () { await using tmp = await tmpdir()
const tmp = yield* tmpdirScoped() const dir = path.join(tmp.path, "a")
const dir = path.join(tmp, "a") await fs.mkdir(dir, { recursive: true })
yield* Effect.promise(() => fs.mkdir(dir, { recursive: true }))
let calls = 0 const ticks: number[] = []
const online = spyOn(Network, "online").mockReturnValue(false) let calls = 0
const ready = Deferred.makeUnsafe<void>() let start = () => {}
const blocked = Deferred.makeUnsafe<void>() let done = () => {}
const hold = Deferred.makeUnsafe<void>() let blocked = () => {}
const target = path.normalize(dir) const ready = new Promise<void>((resolve) => {
const run = spyOn(Npm, "install").mockImplementation(async (d: string) => { start = resolve
if (path.normalize(d) !== target) return })
const gate = new Promise<void>((resolve) => {
done = resolve
})
const waiting = new Promise<void>((resolve) => {
blocked = resolve
})
const online = spyOn(Network, "online").mockReturnValue(false)
const targetDir = dir
const run = spyOn(Npm, "install").mockImplementation(async (d: string) => {
const hit = path.normalize(d) === path.normalize(targetDir)
if (hit) {
calls += 1 calls += 1
Deferred.doneUnsafe(ready, Effect.void) start()
await Effect.runPromise(Deferred.await(hold)) await gate
const mod = path.join(d, "node_modules", "@opencode-ai", "plugin") }
await fs.mkdir(mod, { recursive: true }) const mod = path.join(d, "node_modules", "@opencode-ai", "plugin")
await Filesystem.write( await fs.mkdir(mod, { recursive: true })
path.join(mod, "package.json"), await Filesystem.write(
JSON.stringify({ name: "@opencode-ai/plugin", version: "1.0.0" }), path.join(mod, "package.json"),
) JSON.stringify({ name: "@opencode-ai/plugin", version: "1.0.0" }),
})
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
online.mockRestore()
run.mockRestore()
}),
) )
if (hit) {
start()
await gate
}
})
const first = yield* installDeps(dir).pipe(Effect.forkScoped) try {
yield* Deferred.await(ready) const first = Config.installDependencies(dir)
await ready
let done = false const second = Config.installDependencies(dir, {
const second = yield* installDeps(dir, { waitTick: (tick) => {
waitTick: () => { ticks.push(tick.attempt)
Deferred.doneUnsafe(blocked, Effect.void) blocked()
blocked = () => {}
}, },
}).pipe(
Effect.tap(() =>
Effect.sync(() => {
done = true
}),
),
Effect.forkScoped,
)
yield* Deferred.await(blocked)
expect(done).toBe(false)
yield* Deferred.succeed(hold, void 0)
yield* Fiber.join(first)
yield* Fiber.join(second)
expect(calls).toBe(1)
expect(yield* Effect.promise(() => Filesystem.exists(path.join(dir, "package.json")))).toBe(true)
}),
)
it.live("serializes config dependency installs across dirs", () =>
Effect.gen(function* () {
if (process.platform !== "win32") return
const tmp = yield* tmpdirScoped()
const a = path.join(tmp, "a")
const b = path.join(tmp, "b")
yield* Effect.promise(() => fs.mkdir(a, { recursive: true }))
yield* Effect.promise(() => fs.mkdir(b, { recursive: true }))
let calls = 0
let open = 0
let peak = 0
const ready = Deferred.makeUnsafe<void>()
const blocked = Deferred.makeUnsafe<void>()
const hold = Deferred.makeUnsafe<void>()
const online = spyOn(Network, "online").mockReturnValue(false)
const run = spyOn(Npm, "install").mockImplementation(async (dir: string) => {
const cwd = path.normalize(dir)
const hit = cwd === path.normalize(a) || cwd === path.normalize(b)
if (hit) {
calls += 1
open += 1
peak = Math.max(peak, open)
if (calls === 1) {
Deferred.doneUnsafe(ready, Effect.void)
await Effect.runPromise(Deferred.await(hold))
}
}
const mod = path.join(cwd, "node_modules", "@opencode-ai", "plugin")
await fs.mkdir(mod, { recursive: true })
await Filesystem.write(
path.join(mod, "package.json"),
JSON.stringify({ name: "@opencode-ai/plugin", version: "1.0.0" }),
)
if (hit) {
open -= 1
}
}) })
await waiting
done()
await Promise.all([first, second])
} finally {
online.mockRestore()
run.mockRestore()
}
yield* Effect.addFinalizer(() => expect(calls).toBe(2)
Effect.sync(() => { expect(ticks.length).toBeGreaterThan(0)
online.mockRestore() expect(await Filesystem.exists(path.join(dir, "package.json"))).toBe(true)
run.mockRestore() })
}),
test("serializes config dependency installs across dirs", async () => {
if (process.platform !== "win32") return
await using tmp = await tmpdir()
const a = path.join(tmp.path, "a")
const b = path.join(tmp.path, "b")
await fs.mkdir(a, { recursive: true })
await fs.mkdir(b, { recursive: true })
let calls = 0
let open = 0
let peak = 0
let start = () => {}
let done = () => {}
const ready = new Promise<void>((resolve) => {
start = resolve
})
const gate = new Promise<void>((resolve) => {
done = resolve
})
const online = spyOn(Network, "online").mockReturnValue(false)
const run = spyOn(Npm, "install").mockImplementation(async (dir: string) => {
const cwd = path.normalize(dir)
const hit = cwd === path.normalize(a) || cwd === path.normalize(b)
if (hit) {
calls += 1
open += 1
peak = Math.max(peak, open)
if (calls === 1) {
start()
await gate
}
}
const mod = path.join(cwd, "node_modules", "@opencode-ai", "plugin")
await fs.mkdir(mod, { recursive: true })
await Filesystem.write(
path.join(mod, "package.json"),
JSON.stringify({ name: "@opencode-ai/plugin", version: "1.0.0" }),
) )
if (hit) {
open -= 1
}
})
const first = yield* installDeps(a).pipe(Effect.forkScoped) try {
yield* Deferred.await(ready) const first = Config.installDependencies(a)
await ready
const second = Config.installDependencies(b)
done()
await Promise.all([first, second])
} finally {
online.mockRestore()
run.mockRestore()
}
const second = yield* installDeps(b, { expect(calls).toBe(2)
waitTick: () => { expect(peak).toBe(1)
Deferred.doneUnsafe(blocked, Effect.void) })
},
}).pipe(Effect.forkScoped)
yield* Deferred.await(blocked)
expect(peak).toBe(1)
yield* Deferred.succeed(hold, void 0)
yield* Fiber.join(first)
yield* Fiber.join(second)
expect(calls).toBe(2)
expect(peak).toBe(1)
}),
)
test("resolves scoped npm plugins in config", async () => { test("resolves scoped npm plugins in config", async () => {
await using tmp = await tmpdir({ await using tmp = await tmpdir({
@@ -1006,7 +977,7 @@ test("resolves scoped npm plugins in config", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await load() const config = await Config.get()
const pluginEntries = config.plugin ?? [] const pluginEntries = config.plugin ?? []
expect(pluginEntries).toContain("@scope/plugin") expect(pluginEntries).toContain("@scope/plugin")
}, },
@@ -1044,7 +1015,7 @@ test("merges plugin arrays from global and local configs", async () => {
await Instance.provide({ await Instance.provide({
directory: path.join(tmp.path, "project"), directory: path.join(tmp.path, "project"),
fn: async () => { fn: async () => {
const config = await load() const config = await Config.get()
const plugins = config.plugin ?? [] const plugins = config.plugin ?? []
// Should contain both global and local plugins // Should contain both global and local plugins
@@ -1080,7 +1051,7 @@ Helper subagent prompt`,
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await load() const config = await Config.get()
expect(config.agent?.["helper"]).toMatchObject({ expect(config.agent?.["helper"]).toMatchObject({
name: "helper", name: "helper",
model: "test/model", model: "test/model",
@@ -1119,7 +1090,7 @@ test("merges instructions arrays from global and local configs", async () => {
await Instance.provide({ await Instance.provide({
directory: path.join(tmp.path, "project"), directory: path.join(tmp.path, "project"),
fn: async () => { fn: async () => {
const config = await load() const config = await Config.get()
const instructions = config.instructions ?? [] const instructions = config.instructions ?? []
expect(instructions).toContain("global-instructions.md") expect(instructions).toContain("global-instructions.md")
@@ -1158,7 +1129,7 @@ test("deduplicates duplicate instructions from global and local configs", async
await Instance.provide({ await Instance.provide({
directory: path.join(tmp.path, "project"), directory: path.join(tmp.path, "project"),
fn: async () => { fn: async () => {
const config = await load() const config = await Config.get()
const instructions = config.instructions ?? [] const instructions = config.instructions ?? []
expect(instructions).toContain("global-only.md") expect(instructions).toContain("global-only.md")
@@ -1203,7 +1174,7 @@ test("deduplicates duplicate plugins from global and local configs", async () =>
await Instance.provide({ await Instance.provide({
directory: path.join(tmp.path, "project"), directory: path.join(tmp.path, "project"),
fn: async () => { fn: async () => {
const config = await load() const config = await Config.get()
const plugins = config.plugin ?? [] const plugins = config.plugin ?? []
// Should contain all unique plugins // Should contain all unique plugins
@@ -1252,7 +1223,7 @@ test("keeps plugin origins aligned with merged plugin list", async () => {
await Instance.provide({ await Instance.provide({
directory: path.join(tmp.path, "project"), directory: path.join(tmp.path, "project"),
fn: async () => { fn: async () => {
const cfg = await load() const cfg = await Config.get()
const plugins = cfg.plugin ?? [] const plugins = cfg.plugin ?? []
const origins = cfg.plugin_origins ?? [] const origins = cfg.plugin_origins ?? []
const names = plugins.map((item) => Config.pluginSpecifier(item)) const names = plugins.map((item) => Config.pluginSpecifier(item))
@@ -1293,7 +1264,7 @@ test("migrates legacy tools config to permissions - allow", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await load() const config = await Config.get()
expect(config.agent?.["test"]?.permission).toEqual({ expect(config.agent?.["test"]?.permission).toEqual({
bash: "allow", bash: "allow",
read: "allow", read: "allow",
@@ -1324,7 +1295,7 @@ test("migrates legacy tools config to permissions - deny", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await load() const config = await Config.get()
expect(config.agent?.["test"]?.permission).toEqual({ expect(config.agent?.["test"]?.permission).toEqual({
bash: "deny", bash: "deny",
webfetch: "deny", webfetch: "deny",
@@ -1354,7 +1325,7 @@ test("migrates legacy write tool to edit permission", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await load() const config = await Config.get()
expect(config.agent?.["test"]?.permission).toEqual({ expect(config.agent?.["test"]?.permission).toEqual({
edit: "allow", edit: "allow",
}) })
@@ -1386,7 +1357,7 @@ test("managed settings override user settings", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await load() const config = await Config.get()
expect(config.model).toBe("managed/model") expect(config.model).toBe("managed/model")
expect(config.share).toBe("disabled") expect(config.share).toBe("disabled")
expect(config.username).toBe("testuser") expect(config.username).toBe("testuser")
@@ -1414,7 +1385,7 @@ test("managed settings override project settings", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await load() const config = await Config.get()
expect(config.autoupdate).toBe(false) expect(config.autoupdate).toBe(false)
expect(config.disabled_providers).toEqual(["openai"]) expect(config.disabled_providers).toEqual(["openai"])
}, },
@@ -1434,7 +1405,7 @@ test("missing managed settings file is not an error", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await load() const config = await Config.get()
expect(config.model).toBe("user/model") expect(config.model).toBe("user/model")
}, },
}) })
@@ -1461,7 +1432,7 @@ test("migrates legacy edit tool to edit permission", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await load() const config = await Config.get()
expect(config.agent?.["test"]?.permission).toEqual({ expect(config.agent?.["test"]?.permission).toEqual({
edit: "deny", edit: "deny",
}) })
@@ -1490,7 +1461,7 @@ test("migrates legacy patch tool to edit permission", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await load() const config = await Config.get()
expect(config.agent?.["test"]?.permission).toEqual({ expect(config.agent?.["test"]?.permission).toEqual({
edit: "allow", edit: "allow",
}) })
@@ -1519,7 +1490,7 @@ test("migrates legacy multiedit tool to edit permission", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await load() const config = await Config.get()
expect(config.agent?.["test"]?.permission).toEqual({ expect(config.agent?.["test"]?.permission).toEqual({
edit: "deny", edit: "deny",
}) })
@@ -1551,7 +1522,7 @@ test("migrates mixed legacy tools config", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await load() const config = await Config.get()
expect(config.agent?.["test"]?.permission).toEqual({ expect(config.agent?.["test"]?.permission).toEqual({
bash: "allow", bash: "allow",
edit: "allow", edit: "allow",
@@ -1586,7 +1557,7 @@ test("merges legacy tools with existing permission config", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await load() const config = await Config.get()
expect(config.agent?.["test"]?.permission).toEqual({ expect(config.agent?.["test"]?.permission).toEqual({
glob: "allow", glob: "allow",
bash: "allow", bash: "allow",
@@ -1621,7 +1592,7 @@ test("permission config preserves key order", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await load() const config = await Config.get()
expect(Object.keys(config.permission!)).toEqual([ expect(Object.keys(config.permission!)).toEqual([
"*", "*",
"edit", "edit",
@@ -1681,7 +1652,7 @@ test("project config can override MCP server enabled status", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await load() const config = await Config.get()
// jira should be enabled (overridden by project config) // jira should be enabled (overridden by project config)
expect(config.mcp?.jira).toEqual({ expect(config.mcp?.jira).toEqual({
type: "remote", type: "remote",
@@ -1737,7 +1708,7 @@ test("MCP config deep merges preserving base config properties", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await load() const config = await Config.get()
expect(config.mcp?.myserver).toEqual({ expect(config.mcp?.myserver).toEqual({
type: "remote", type: "remote",
url: "https://myserver.example.com/mcp", url: "https://myserver.example.com/mcp",
@@ -1788,7 +1759,7 @@ test("local .opencode config can override MCP from project config", async () =>
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await load() const config = await Config.get()
expect(config.mcp?.docs?.enabled).toBe(true) expect(config.mcp?.docs?.enabled).toBe(true)
}, },
}) })
@@ -2039,7 +2010,7 @@ describe("deduplicatePluginOrigins", () => {
await Instance.provide({ await Instance.provide({
directory: path.join(tmp.path, "project"), directory: path.join(tmp.path, "project"),
fn: async () => { fn: async () => {
const config = await load() const config = await Config.get()
const plugins = config.plugin ?? [] const plugins = config.plugin ?? []
expect(plugins.some((p) => Config.pluginSpecifier(p) === "my-plugin@1.0.0")).toBe(true) expect(plugins.some((p) => Config.pluginSpecifier(p) === "my-plugin@1.0.0")).toBe(true)
@@ -2071,7 +2042,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await load() const config = await Config.get()
// Project config should NOT be loaded - model should be default, not "project/model" // Project config should NOT be loaded - model should be default, not "project/model"
expect(config.model).not.toBe("project/model") expect(config.model).not.toBe("project/model")
expect(config.username).not.toBe("project-user") expect(config.username).not.toBe("project-user")
@@ -2102,7 +2073,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const directories = await listDirs() const directories = await Config.directories()
// Project .opencode should NOT be in directories list // Project .opencode should NOT be in directories list
const hasProjectOpencode = directories.some((d) => d.startsWith(tmp.path)) const hasProjectOpencode = directories.some((d) => d.startsWith(tmp.path))
expect(hasProjectOpencode).toBe(false) expect(hasProjectOpencode).toBe(false)
@@ -2127,7 +2098,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
// Should still get default config (from global or defaults) // Should still get default config (from global or defaults)
const config = await load() const config = await Config.get()
expect(config).toBeDefined() expect(config).toBeDefined()
expect(config.username).toBeDefined() expect(config.username).toBeDefined()
}, },
@@ -2170,7 +2141,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
fn: async () => { fn: async () => {
// The relative instruction should be skipped without error // The relative instruction should be skipped without error
// We're mainly verifying this doesn't throw and the config loads // We're mainly verifying this doesn't throw and the config loads
const config = await load() const config = await Config.get()
expect(config).toBeDefined() expect(config).toBeDefined()
// The instruction should have been skipped (warning logged) // The instruction should have been skipped (warning logged)
// We can't easily test the warning was logged, but we verify // We can't easily test the warning was logged, but we verify
@@ -2228,7 +2199,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
await Instance.provide({ await Instance.provide({
directory: projectTmp.path, directory: projectTmp.path,
fn: async () => { fn: async () => {
const config = await load() const config = await Config.get()
// Should load from OPENCODE_CONFIG_DIR, not project // Should load from OPENCODE_CONFIG_DIR, not project
expect(config.model).toBe("configdir/model") expect(config.model).toBe("configdir/model")
}, },
@@ -2263,7 +2234,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await load() const config = await Config.get()
expect(config.username).toBe("test_api_key_12345") expect(config.username).toBe("test_api_key_12345")
}, },
}) })
@@ -2297,7 +2268,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await load() const config = await Config.get()
expect(config.username).toBe("secret_key_from_file") expect(config.username).toBe("secret_key_from_file")
}, },
}) })
+3 -6
View File
@@ -7,15 +7,12 @@ import { Config } from "../../src/config/config"
import { TuiConfig } from "../../src/config/tui" import { TuiConfig } from "../../src/config/tui"
import { Global } from "../../src/global" import { Global } from "../../src/global"
import { Filesystem } from "../../src/util/filesystem" import { Filesystem } from "../../src/util/filesystem"
import { AppRuntime } from "../../src/effect/app-runtime"
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR! const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
const wintest = process.platform === "win32" ? test : test.skip const wintest = process.platform === "win32" ? test : test.skip
const clear = (wait = false) => AppRuntime.runPromise(Config.Service.use((svc) => svc.invalidate(wait)))
const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get()))
beforeEach(async () => { beforeEach(async () => {
await clear(true) await Config.invalidate(true)
}) })
afterEach(async () => { afterEach(async () => {
@@ -26,7 +23,7 @@ afterEach(async () => {
await fs.rm(path.join(Global.Path.config, "tui.json"), { force: true }).catch(() => {}) await fs.rm(path.join(Global.Path.config, "tui.json"), { force: true }).catch(() => {})
await fs.rm(path.join(Global.Path.config, "tui.jsonc"), { force: true }).catch(() => {}) await fs.rm(path.join(Global.Path.config, "tui.jsonc"), { force: true }).catch(() => {})
await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {}) await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {})
await clear(true) await Config.invalidate(true)
}) })
test("keeps server and tui plugin merge semantics aligned", async () => { test("keeps server and tui plugin merge semantics aligned", async () => {
@@ -82,7 +79,7 @@ test("keeps server and tui plugin merge semantics aligned", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const server = await load() const server = await Config.get()
const tui = await TuiConfig.get() const tui = await TuiConfig.get()
const serverPlugins = (server.plugin ?? []).map((item) => Config.pluginSpecifier(item)) const serverPlugins = (server.plugin ?? []).map((item) => Config.pluginSpecifier(item))
const tuiPlugins = (tui.plugin ?? []).map((item) => Config.pluginSpecifier(item)) const tuiPlugins = (tui.plugin ?? []).map((item) => Config.pluginSpecifier(item))
@@ -1,71 +0,0 @@
import { describe, expect, test } from "bun:test"
import { getAdaptor, registerAdaptor } from "../../src/control-plane/adaptors"
import { ProjectID } from "../../src/project/schema"
import type { WorkspaceInfo } from "../../src/control-plane/types"
function info(projectID: WorkspaceInfo["projectID"], type: string): WorkspaceInfo {
return {
id: "workspace-test" as WorkspaceInfo["id"],
type,
name: "workspace-test",
branch: null,
directory: null,
extra: null,
projectID,
}
}
function adaptor(dir: string) {
return {
name: dir,
description: dir,
configure(input: WorkspaceInfo) {
return input
},
async create() {},
async remove() {},
target() {
return {
type: "local" as const,
directory: dir,
}
},
}
}
describe("control-plane/adaptors", () => {
test("isolates custom adaptors by project", async () => {
const type = `demo-${Math.random().toString(36).slice(2)}`
const one = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`)
const two = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`)
registerAdaptor(one, type, adaptor("/one"))
registerAdaptor(two, type, adaptor("/two"))
expect(await (await getAdaptor(one, type)).target(info(one, type))).toEqual({
type: "local",
directory: "/one",
})
expect(await (await getAdaptor(two, type)).target(info(two, type))).toEqual({
type: "local",
directory: "/two",
})
})
test("latest install wins within a project", async () => {
const type = `demo-${Math.random().toString(36).slice(2)}`
const id = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`)
registerAdaptor(id, type, adaptor("/one"))
expect(await (await getAdaptor(id, type)).target(info(id, type))).toEqual({
type: "local",
directory: "/one",
})
registerAdaptor(id, type, adaptor("/two"))
expect(await (await getAdaptor(id, type)).target(info(id, type))).toEqual({
type: "local",
directory: "/two",
})
})
})
@@ -1,16 +1,10 @@
import { $ } from "bun" import { $ } from "bun"
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import fs from "fs/promises" import fs from "fs/promises"
import path from "path" import path from "path"
import { File } from "../../src/file" import { File } from "../../src/file"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { provideInstance, tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
const run = <A, E>(eff: Effect.Effect<A, E, File.Service>) =>
Effect.runPromise(provideInstance(Instance.directory)(eff.pipe(Effect.provide(File.defaultLayer))))
const status = () => run(File.Service.use((svc) => svc.status()))
const read = (file: string) => run(File.Service.use((svc) => svc.read(file)))
const wintest = process.platform === "win32" ? test : test.skip const wintest = process.platform === "win32" ? test : test.skip
@@ -33,7 +27,7 @@ describe("file fsmonitor", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await status() await File.status()
}, },
}) })
@@ -58,7 +52,7 @@ describe("file fsmonitor", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await read("tracked.txt") await File.read("tracked.txt")
}, },
}) })
+78 -88
View File
@@ -1,28 +1,18 @@
import { afterEach, describe, test, expect } from "bun:test" import { afterEach, describe, test, expect } from "bun:test"
import { $ } from "bun" import { $ } from "bun"
import { Effect } from "effect"
import path from "path" import path from "path"
import fs from "fs/promises" import fs from "fs/promises"
import { File } from "../../src/file" import { File } from "../../src/file"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { Filesystem } from "../../src/util/filesystem" import { Filesystem } from "../../src/util/filesystem"
import { provideInstance, tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
afterEach(async () => { afterEach(async () => {
await Instance.disposeAll() await Instance.disposeAll()
}) })
const init = () => run(File.Service.use((svc) => svc.init()))
const run = <A, E>(eff: Effect.Effect<A, E, File.Service>) =>
Effect.runPromise(provideInstance(Instance.directory)(eff.pipe(Effect.provide(File.defaultLayer))))
const status = () => run(File.Service.use((svc) => svc.status()))
const read = (file: string) => run(File.Service.use((svc) => svc.read(file)))
const list = (dir?: string) => run(File.Service.use((svc) => svc.list(dir)))
const search = (input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) =>
run(File.Service.use((svc) => svc.search(input)))
describe("file/index Filesystem patterns", () => { describe("file/index Filesystem patterns", () => {
describe("read() - text content", () => { describe("File.read() - text content", () => {
test("reads text file via Filesystem.readText()", async () => { test("reads text file via Filesystem.readText()", async () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "test.txt") const filepath = path.join(tmp.path, "test.txt")
@@ -31,7 +21,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const result = await read("test.txt") const result = await File.read("test.txt")
expect(result.type).toBe("text") expect(result.type).toBe("text")
expect(result.content).toBe("Hello World") expect(result.content).toBe("Hello World")
}, },
@@ -45,7 +35,7 @@ describe("file/index Filesystem patterns", () => {
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
// Non-existent file should return empty content // Non-existent file should return empty content
const result = await read("nonexistent.txt") const result = await File.read("nonexistent.txt")
expect(result.type).toBe("text") expect(result.type).toBe("text")
expect(result.content).toBe("") expect(result.content).toBe("")
}, },
@@ -60,7 +50,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const result = await read("test.txt") const result = await File.read("test.txt")
expect(result.content).toBe("content with spaces") expect(result.content).toBe("content with spaces")
}, },
}) })
@@ -74,7 +64,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const result = await read("empty.txt") const result = await File.read("empty.txt")
expect(result.type).toBe("text") expect(result.type).toBe("text")
expect(result.content).toBe("") expect(result.content).toBe("")
}, },
@@ -89,14 +79,14 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const result = await read("multiline.txt") const result = await File.read("multiline.txt")
expect(result.content).toBe("line1\nline2\nline3") expect(result.content).toBe("line1\nline2\nline3")
}, },
}) })
}) })
}) })
describe("read() - binary content", () => { describe("File.read() - binary content", () => {
test("reads binary file via Filesystem.readArrayBuffer()", async () => { test("reads binary file via Filesystem.readArrayBuffer()", async () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "image.png") const filepath = path.join(tmp.path, "image.png")
@@ -106,7 +96,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const result = await read("image.png") const result = await File.read("image.png")
expect(result.type).toBe("text") // Images return as text with base64 encoding expect(result.type).toBe("text") // Images return as text with base64 encoding
expect(result.encoding).toBe("base64") expect(result.encoding).toBe("base64")
expect(result.mimeType).toBe("image/png") expect(result.mimeType).toBe("image/png")
@@ -123,7 +113,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const result = await read("binary.so") const result = await File.read("binary.so")
expect(result.type).toBe("binary") expect(result.type).toBe("binary")
expect(result.content).toBe("") expect(result.content).toBe("")
}, },
@@ -131,7 +121,7 @@ describe("file/index Filesystem patterns", () => {
}) })
}) })
describe("read() - Filesystem.mimeType()", () => { describe("File.read() - Filesystem.mimeType()", () => {
test("detects MIME type via Filesystem.mimeType()", async () => { test("detects MIME type via Filesystem.mimeType()", async () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "test.json") const filepath = path.join(tmp.path, "test.json")
@@ -142,7 +132,7 @@ describe("file/index Filesystem patterns", () => {
fn: async () => { fn: async () => {
expect(Filesystem.mimeType(filepath)).toContain("application/json") expect(Filesystem.mimeType(filepath)).toContain("application/json")
const result = await read("test.json") const result = await File.read("test.json")
expect(result.type).toBe("text") expect(result.type).toBe("text")
}, },
}) })
@@ -171,7 +161,7 @@ describe("file/index Filesystem patterns", () => {
}) })
}) })
describe("list() - Filesystem.exists() and readText()", () => { describe("File.list() - Filesystem.exists() and readText()", () => {
test("reads .gitignore via Filesystem.exists() and readText()", async () => { test("reads .gitignore via Filesystem.exists() and readText()", async () => {
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
@@ -181,7 +171,7 @@ describe("file/index Filesystem patterns", () => {
const gitignorePath = path.join(tmp.path, ".gitignore") const gitignorePath = path.join(tmp.path, ".gitignore")
await fs.writeFile(gitignorePath, "node_modules\ndist\n", "utf-8") await fs.writeFile(gitignorePath, "node_modules\ndist\n", "utf-8")
// This is used internally in list() // This is used internally in File.list()
expect(await Filesystem.exists(gitignorePath)).toBe(true) expect(await Filesystem.exists(gitignorePath)).toBe(true)
const content = await Filesystem.readText(gitignorePath) const content = await Filesystem.readText(gitignorePath)
@@ -214,8 +204,8 @@ describe("file/index Filesystem patterns", () => {
const gitignorePath = path.join(tmp.path, ".gitignore") const gitignorePath = path.join(tmp.path, ".gitignore")
expect(await Filesystem.exists(gitignorePath)).toBe(false) expect(await Filesystem.exists(gitignorePath)).toBe(false)
// list() should still work // File.list() should still work
const nodes = await list() const nodes = await File.list()
expect(Array.isArray(nodes)).toBe(true) expect(Array.isArray(nodes)).toBe(true)
}, },
}) })
@@ -254,8 +244,8 @@ describe("file/index Filesystem patterns", () => {
// Filesystem.readText() on non-existent file throws // Filesystem.readText() on non-existent file throws
await expect(Filesystem.readText(nonExistentPath)).rejects.toThrow() await expect(Filesystem.readText(nonExistentPath)).rejects.toThrow()
// But read() handles this gracefully // But File.read() handles this gracefully
const result = await read("does-not-exist.txt") const result = await File.read("does-not-exist.txt")
expect(result.content).toBe("") expect(result.content).toBe("")
}, },
}) })
@@ -282,8 +272,8 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
// read() handles missing images gracefully // File.read() handles missing images gracefully
const result = await read("broken.png") const result = await File.read("broken.png")
expect(result.type).toBe("text") expect(result.type).toBe("text")
expect(result.content).toBe("") expect(result.content).toBe("")
}, },
@@ -300,7 +290,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const result = await read("test.ts") const result = await File.read("test.ts")
expect(result.type).toBe("text") expect(result.type).toBe("text")
expect(result.content).toBe("export const value = 1") expect(result.content).toBe("export const value = 1")
}, },
@@ -315,7 +305,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const result = await read("test.mts") const result = await File.read("test.mts")
expect(result.type).toBe("text") expect(result.type).toBe("text")
expect(result.content).toBe("export const value = 1") expect(result.content).toBe("export const value = 1")
}, },
@@ -330,7 +320,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const result = await read("test.sh") const result = await File.read("test.sh")
expect(result.type).toBe("text") expect(result.type).toBe("text")
expect(result.content).toBe("#!/usr/bin/env bash\necho hello") expect(result.content).toBe("#!/usr/bin/env bash\necho hello")
}, },
@@ -345,7 +335,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const result = await read("Dockerfile") const result = await File.read("Dockerfile")
expect(result.type).toBe("text") expect(result.type).toBe("text")
expect(result.content).toBe("FROM alpine:3.20") expect(result.content).toBe("FROM alpine:3.20")
}, },
@@ -360,7 +350,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const result = await read("test.txt") const result = await File.read("test.txt")
expect(result.encoding).toBeUndefined() expect(result.encoding).toBeUndefined()
expect(result.type).toBe("text") expect(result.type).toBe("text")
}, },
@@ -375,7 +365,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const result = await read("test.jpg") const result = await File.read("test.jpg")
expect(result.encoding).toBe("base64") expect(result.encoding).toBe("base64")
expect(result.mimeType).toBe("image/jpeg") expect(result.mimeType).toBe("image/jpeg")
}, },
@@ -390,7 +380,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await expect(read("../outside.txt")).rejects.toThrow("Access denied") await expect(File.read("../outside.txt")).rejects.toThrow("Access denied")
}, },
}) })
}) })
@@ -401,13 +391,13 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await expect(read("../outside.txt")).rejects.toThrow("Access denied") await expect(File.read("../outside.txt")).rejects.toThrow("Access denied")
}, },
}) })
}) })
}) })
describe("status()", () => { describe("File.status()", () => {
test("detects modified file", async () => { test("detects modified file", async () => {
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
const filepath = path.join(tmp.path, "file.txt") const filepath = path.join(tmp.path, "file.txt")
@@ -419,7 +409,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const result = await status() const result = await File.status()
const entry = result.find((f) => f.path === "file.txt") const entry = result.find((f) => f.path === "file.txt")
expect(entry).toBeDefined() expect(entry).toBeDefined()
expect(entry!.status).toBe("modified") expect(entry!.status).toBe("modified")
@@ -436,7 +426,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const result = await status() const result = await File.status()
const entry = result.find((f) => f.path === "new.txt") const entry = result.find((f) => f.path === "new.txt")
expect(entry).toBeDefined() expect(entry).toBeDefined()
expect(entry!.status).toBe("added") expect(entry!.status).toBe("added")
@@ -457,7 +447,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const result = await status() const result = await File.status()
// Deleted files appear in both numstat (as "modified") and diff-filter=D (as "deleted") // Deleted files appear in both numstat (as "modified") and diff-filter=D (as "deleted")
const entries = result.filter((f) => f.path === "gone.txt") const entries = result.filter((f) => f.path === "gone.txt")
expect(entries.some((e) => e.status === "deleted")).toBe(true) expect(entries.some((e) => e.status === "deleted")).toBe(true)
@@ -480,7 +470,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const result = await status() const result = await File.status()
expect(result.some((f) => f.path === "keep.txt" && f.status === "modified")).toBe(true) expect(result.some((f) => f.path === "keep.txt" && f.status === "modified")).toBe(true)
expect(result.some((f) => f.path === "remove.txt" && f.status === "deleted")).toBe(true) expect(result.some((f) => f.path === "remove.txt" && f.status === "deleted")).toBe(true)
expect(result.some((f) => f.path === "brand-new.txt" && f.status === "added")).toBe(true) expect(result.some((f) => f.path === "brand-new.txt" && f.status === "added")).toBe(true)
@@ -494,7 +484,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const result = await status() const result = await File.status()
expect(result).toEqual([]) expect(result).toEqual([])
}, },
}) })
@@ -506,7 +496,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const result = await status() const result = await File.status()
expect(result).toEqual([]) expect(result).toEqual([])
}, },
}) })
@@ -529,7 +519,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const result = await status() const result = await File.status()
const entry = result.find((f) => f.path === "data.bin") const entry = result.find((f) => f.path === "data.bin")
expect(entry).toBeDefined() expect(entry).toBeDefined()
expect(entry!.status).toBe("modified") expect(entry!.status).toBe("modified")
@@ -540,7 +530,7 @@ describe("file/index Filesystem patterns", () => {
}) })
}) })
describe("list()", () => { describe("File.list()", () => {
test("returns files and directories with correct shape", async () => { test("returns files and directories with correct shape", async () => {
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
await fs.mkdir(path.join(tmp.path, "subdir")) await fs.mkdir(path.join(tmp.path, "subdir"))
@@ -550,7 +540,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const nodes = await list() const nodes = await File.list()
expect(nodes.length).toBeGreaterThanOrEqual(2) expect(nodes.length).toBeGreaterThanOrEqual(2)
for (const node of nodes) { for (const node of nodes) {
expect(node).toHaveProperty("name") expect(node).toHaveProperty("name")
@@ -574,7 +564,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const nodes = await list() const nodes = await File.list()
const dirs = nodes.filter((n) => n.type === "directory") const dirs = nodes.filter((n) => n.type === "directory")
const files = nodes.filter((n) => n.type === "file") const files = nodes.filter((n) => n.type === "file")
// Dirs come first // Dirs come first
@@ -599,7 +589,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const nodes = await list() const nodes = await File.list()
const names = nodes.map((n) => n.name) const names = nodes.map((n) => n.name)
expect(names).not.toContain(".git") expect(names).not.toContain(".git")
expect(names).not.toContain(".DS_Store") expect(names).not.toContain(".DS_Store")
@@ -618,7 +608,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const nodes = await list() const nodes = await File.list()
const logNode = nodes.find((n) => n.name === "app.log") const logNode = nodes.find((n) => n.name === "app.log")
const tsNode = nodes.find((n) => n.name === "main.ts") const tsNode = nodes.find((n) => n.name === "main.ts")
const buildNode = nodes.find((n) => n.name === "build") const buildNode = nodes.find((n) => n.name === "build")
@@ -638,7 +628,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const nodes = await list("sub") const nodes = await File.list("sub")
expect(nodes.length).toBe(2) expect(nodes.length).toBe(2)
expect(nodes.map((n) => n.name).sort()).toEqual(["a.txt", "b.txt"]) expect(nodes.map((n) => n.name).sort()).toEqual(["a.txt", "b.txt"])
// Paths should be relative to project root (normalize for Windows) // Paths should be relative to project root (normalize for Windows)
@@ -653,7 +643,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await expect(list("../outside")).rejects.toThrow("Access denied") await expect(File.list("../outside")).rejects.toThrow("Access denied")
}, },
}) })
}) })
@@ -665,7 +655,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const nodes = await list() const nodes = await File.list()
expect(nodes.length).toBeGreaterThanOrEqual(1) expect(nodes.length).toBeGreaterThanOrEqual(1)
// Without git, ignored should be false for all // Without git, ignored should be false for all
for (const node of nodes) { for (const node of nodes) {
@@ -676,7 +666,7 @@ describe("file/index Filesystem patterns", () => {
}) })
}) })
describe("search()", () => { describe("File.search()", () => {
async function setupSearchableRepo() { async function setupSearchableRepo() {
const tmp = await tmpdir({ git: true }) const tmp = await tmpdir({ git: true })
await fs.writeFile(path.join(tmp.path, "index.ts"), "code", "utf-8") await fs.writeFile(path.join(tmp.path, "index.ts"), "code", "utf-8")
@@ -695,9 +685,9 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await init() await File.init()
const result = await search({ query: "", type: "file" }) const result = await File.search({ query: "", type: "file" })
expect(result.length).toBeGreaterThan(0) expect(result.length).toBeGreaterThan(0)
}, },
}) })
@@ -709,7 +699,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const result = await search({ query: "main", type: "file" }) const result = await File.search({ query: "main", type: "file" })
expect(result.some((f) => f.includes("main"))).toBe(true) expect(result.some((f) => f.includes("main"))).toBe(true)
}, },
}) })
@@ -721,9 +711,9 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await init() await File.init()
const result = await search({ query: "", type: "directory" }) const result = await File.search({ query: "", type: "directory" })
expect(result.length).toBeGreaterThan(0) expect(result.length).toBeGreaterThan(0)
// Find first hidden dir index // Find first hidden dir index
const firstHidden = result.findIndex((d) => d.split("/").some((p) => p.startsWith(".") && p.length > 1)) const firstHidden = result.findIndex((d) => d.split("/").some((p) => p.startsWith(".") && p.length > 1))
@@ -741,9 +731,9 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await init() await File.init()
const result = await search({ query: "main", type: "file" }) const result = await File.search({ query: "main", type: "file" })
expect(result.some((f) => f.includes("main"))).toBe(true) expect(result.some((f) => f.includes("main"))).toBe(true)
}, },
}) })
@@ -755,9 +745,9 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await init() await File.init()
const result = await search({ query: "", type: "file" }) const result = await File.search({ query: "", type: "file" })
// Files don't end with / // Files don't end with /
for (const f of result) { for (const f of result) {
expect(f.endsWith("/")).toBe(false) expect(f.endsWith("/")).toBe(false)
@@ -772,9 +762,9 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await init() await File.init()
const result = await search({ query: "", type: "directory" }) const result = await File.search({ query: "", type: "directory" })
// Directories end with / // Directories end with /
for (const d of result) { for (const d of result) {
expect(d.endsWith("/")).toBe(true) expect(d.endsWith("/")).toBe(true)
@@ -789,9 +779,9 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await init() await File.init()
const result = await search({ query: "", type: "file", limit: 2 }) const result = await File.search({ query: "", type: "file", limit: 2 })
expect(result.length).toBeLessThanOrEqual(2) expect(result.length).toBeLessThanOrEqual(2)
}, },
}) })
@@ -803,9 +793,9 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await init() await File.init()
const result = await search({ query: ".hidden", type: "directory" }) const result = await File.search({ query: ".hidden", type: "directory" })
expect(result.length).toBeGreaterThan(0) expect(result.length).toBeGreaterThan(0)
expect(result[0]).toContain(".hidden") expect(result[0]).toContain(".hidden")
}, },
@@ -818,19 +808,19 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await init() await File.init()
expect(await search({ query: "fresh", type: "file" })).toEqual([]) expect(await File.search({ query: "fresh", type: "file" })).toEqual([])
await fs.writeFile(path.join(tmp.path, "fresh.ts"), "fresh", "utf-8") await fs.writeFile(path.join(tmp.path, "fresh.ts"), "fresh", "utf-8")
const result = await search({ query: "fresh", type: "file" }) const result = await File.search({ query: "fresh", type: "file" })
expect(result).toContain("fresh.ts") expect(result).toContain("fresh.ts")
}, },
}) })
}) })
}) })
describe("read() - diff/patch", () => { describe("File.read() - diff/patch", () => {
test("returns diff and patch for modified tracked file", async () => { test("returns diff and patch for modified tracked file", async () => {
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
const filepath = path.join(tmp.path, "file.txt") const filepath = path.join(tmp.path, "file.txt")
@@ -842,7 +832,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const result = await read("file.txt") const result = await File.read("file.txt")
expect(result.type).toBe("text") expect(result.type).toBe("text")
expect(result.content).toBe("modified content") expect(result.content).toBe("modified content")
expect(result.diff).toBeDefined() expect(result.diff).toBeDefined()
@@ -866,7 +856,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const result = await read("staged.txt") const result = await File.read("staged.txt")
expect(result.diff).toBeDefined() expect(result.diff).toBeDefined()
expect(result.patch).toBeDefined() expect(result.patch).toBeDefined()
}, },
@@ -883,7 +873,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const result = await read("clean.txt") const result = await File.read("clean.txt")
expect(result.type).toBe("text") expect(result.type).toBe("text")
expect(result.content).toBe("unchanged") expect(result.content).toBe("unchanged")
expect(result.diff).toBeUndefined() expect(result.diff).toBeUndefined()
@@ -903,10 +893,10 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({ await Instance.provide({
directory: one.path, directory: one.path,
fn: async () => { fn: async () => {
await init() await File.init()
const results = await search({ query: "a.ts", type: "file" }) const results = await File.search({ query: "a.ts", type: "file" })
expect(results).toContain("a.ts") expect(results).toContain("a.ts")
const results2 = await search({ query: "b.ts", type: "file" }) const results2 = await File.search({ query: "b.ts", type: "file" })
expect(results2).not.toContain("b.ts") expect(results2).not.toContain("b.ts")
}, },
}) })
@@ -914,10 +904,10 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({ await Instance.provide({
directory: two.path, directory: two.path,
fn: async () => { fn: async () => {
await init() await File.init()
const results = await search({ query: "b.ts", type: "file" }) const results = await File.search({ query: "b.ts", type: "file" })
expect(results).toContain("b.ts") expect(results).toContain("b.ts")
const results2 = await search({ query: "a.ts", type: "file" }) const results2 = await File.search({ query: "a.ts", type: "file" })
expect(results2).not.toContain("a.ts") expect(results2).not.toContain("a.ts")
}, },
}) })
@@ -930,8 +920,8 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await init() await File.init()
const results = await search({ query: "before", type: "file" }) const results = await File.search({ query: "before", type: "file" })
expect(results).toContain("before.ts") expect(results).toContain("before.ts")
}, },
}) })
@@ -944,10 +934,10 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await init() await File.init()
const results = await search({ query: "after", type: "file" }) const results = await File.search({ query: "after", type: "file" })
expect(results).toContain("after.ts") expect(results).toContain("after.ts")
const stale = await search({ query: "before", type: "file" }) const stale = await File.search({ query: "before", type: "file" })
expect(stale).not.toContain("before.ts") expect(stale).not.toContain("before.ts")
}, },
}) })
@@ -1,16 +1,10 @@
import { test, expect, describe } from "bun:test" import { test, expect, describe } from "bun:test"
import { Effect } from "effect"
import path from "path" import path from "path"
import fs from "fs/promises" import fs from "fs/promises"
import { Filesystem } from "../../src/util/filesystem" import { Filesystem } from "../../src/util/filesystem"
import { File } from "../../src/file" import { File } from "../../src/file"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { provideInstance, tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
const run = <A, E>(eff: Effect.Effect<A, E, File.Service>) =>
Effect.runPromise(provideInstance(Instance.directory)(eff.pipe(Effect.provide(File.defaultLayer))))
const read = (file: string) => run(File.Service.use((svc) => svc.read(file)))
const list = (dir?: string) => run(File.Service.use((svc) => svc.list(dir)))
describe("Filesystem.contains", () => { describe("Filesystem.contains", () => {
test("allows paths within project", () => { test("allows paths within project", () => {
@@ -38,10 +32,10 @@ describe("Filesystem.contains", () => {
}) })
/* /*
* Integration tests for read() and list() path traversal protection. * Integration tests for File.read() and File.list() path traversal protection.
* *
* These tests verify the HTTP API code path is protected. The HTTP endpoints * These tests verify the HTTP API code path is protected. The HTTP endpoints
* in server.ts (GET /file/content, GET /file) call read()/list() * in server.ts (GET /file/content, GET /file) call File.read()/File.list()
* directly - they do NOT go through ReadTool or the agent permission layer. * directly - they do NOT go through ReadTool or the agent permission layer.
* *
* This is a SEPARATE code path from ReadTool, which has its own checks. * This is a SEPARATE code path from ReadTool, which has its own checks.
@@ -57,7 +51,7 @@ describe("File.read path traversal protection", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await expect(read("../../../etc/passwd")).rejects.toThrow("Access denied: path escapes project directory") await expect(File.read("../../../etc/passwd")).rejects.toThrow("Access denied: path escapes project directory")
}, },
}) })
}) })
@@ -68,7 +62,7 @@ describe("File.read path traversal protection", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await expect(read("src/nested/../../../../../../../etc/passwd")).rejects.toThrow( await expect(File.read("src/nested/../../../../../../../etc/passwd")).rejects.toThrow(
"Access denied: path escapes project directory", "Access denied: path escapes project directory",
) )
}, },
@@ -85,7 +79,7 @@ describe("File.read path traversal protection", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const result = await read("valid.txt") const result = await File.read("valid.txt")
expect(result.content).toBe("valid content") expect(result.content).toBe("valid content")
}, },
}) })
@@ -99,7 +93,7 @@ describe("File.list path traversal protection", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await expect(list("../../../etc")).rejects.toThrow("Access denied: path escapes project directory") await expect(File.list("../../../etc")).rejects.toThrow("Access denied: path escapes project directory")
}, },
}) })
}) })
@@ -114,7 +108,7 @@ describe("File.list path traversal protection", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const result = await list("subdir") const result = await File.list("subdir")
expect(Array.isArray(result)).toBe(true) expect(Array.isArray(result)).toBe(true)
}, },
}) })
+6 -26
View File
@@ -38,9 +38,7 @@ describe("file.ripgrep", () => {
expect(hasVisible).toBe(true) expect(hasVisible).toBe(true)
expect(hasHidden).toBe(false) expect(hasHidden).toBe(false)
}) })
})
describe("Ripgrep.Service", () => {
test("search returns empty when nothing matches", async () => { test("search returns empty when nothing matches", async () => {
await using tmp = await tmpdir({ await using tmp = await tmpdir({
init: async (dir) => { init: async (dir) => {
@@ -48,34 +46,16 @@ describe("Ripgrep.Service", () => {
}, },
}) })
const result = await Effect.gen(function* () { const hits = await Ripgrep.search({
const rg = yield* Ripgrep.Service cwd: tmp.path,
return yield* rg.search({ cwd: tmp.path, pattern: "needle" }) pattern: "needle",
}).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise)
expect(result.partial).toBe(false)
expect(result.items).toEqual([])
})
test("search returns matched rows", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "match.ts"), "const value = 'needle'\n")
await Bun.write(path.join(dir, "skip.txt"), "const value = 'other'\n")
},
}) })
const result = await Effect.gen(function* () { expect(hits).toEqual([])
const rg = yield* Ripgrep.Service
return yield* rg.search({ cwd: tmp.path, pattern: "needle", glob: ["*.ts"] })
}).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise)
expect(result.partial).toBe(false)
expect(result.items).toHaveLength(1)
expect(result.items[0]?.path.text).toContain("match.ts")
expect(result.items[0]?.lines.text).toContain("needle")
}) })
})
describe("Ripgrep.Service", () => {
test("files returns stream of filenames", async () => { test("files returns stream of filenames", async () => {
await using tmp = await tmpdir({ await using tmp = await tmpdir({
init: async (dir) => { init: async (dir) => {
+47 -47
View File
@@ -1,55 +1,55 @@
import { describe, expect, spyOn } from "bun:test" import { describe, expect, spyOn, test } from "bun:test"
import path from "path" import path from "path"
import { Effect, Layer } from "effect" import * as Lsp from "../../src/lsp/index"
import { LSP } from "../../src/lsp"
import { LSPServer } from "../../src/lsp/server" import { LSPServer } from "../../src/lsp/server"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { Instance } from "../../src/project/instance"
import { provideTmpdirInstance } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const it = testEffect(Layer.mergeAll(LSP.defaultLayer, CrossSpawnSpawner.defaultLayer))
describe("lsp.spawn", () => { describe("lsp.spawn", () => {
it.live("does not spawn builtin LSP for files outside instance", () => test("does not spawn builtin LSP for files outside instance", async () => {
provideTmpdirInstance((dir) => await using tmp = await tmpdir()
LSP.Service.use((lsp) => const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined)
Effect.gen(function* () {
const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined)
try { try {
yield* lsp.touchFile(path.join(dir, "..", "outside.ts")) await Instance.provide({
yield* lsp.hover({ directory: tmp.path,
file: path.join(dir, "..", "hover.ts"), fn: async () => {
line: 0, await Lsp.LSP.touchFile(path.join(tmp.path, "..", "outside.ts"))
character: 0, await Lsp.LSP.hover({
}) file: path.join(tmp.path, "..", "hover.ts"),
expect(spy).toHaveBeenCalledTimes(0) line: 0,
} finally { character: 0,
spy.mockRestore() })
} },
}), })
),
),
)
it.live("would spawn builtin LSP for files inside instance", () => expect(spy).toHaveBeenCalledTimes(0)
provideTmpdirInstance((dir) => } finally {
LSP.Service.use((lsp) => spy.mockRestore()
Effect.gen(function* () { await Instance.disposeAll()
const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined) }
})
try { test("would spawn builtin LSP for files inside instance", async () => {
yield* lsp.hover({ await using tmp = await tmpdir()
file: path.join(dir, "src", "inside.ts"), const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined)
line: 0,
character: 0, try {
}) await Instance.provide({
expect(spy).toHaveBeenCalledTimes(1) directory: tmp.path,
} finally { fn: async () => {
spy.mockRestore() await Lsp.LSP.hover({
} file: path.join(tmp.path, "src", "inside.ts"),
}), line: 0,
), character: 0,
), })
) },
})
expect(spy).toHaveBeenCalledTimes(1)
} finally {
spy.mockRestore()
await Instance.disposeAll()
}
})
}) })
+91 -96
View File
@@ -1,13 +1,23 @@
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" import { describe, expect, test, spyOn, beforeEach, afterEach } from "bun:test"
import path from "path" import path from "path"
import { Effect, Layer } from "effect" import * as Lsp from "../../src/lsp/index"
import { LSP } from "../../src/lsp"
import { LSPServer } from "../../src/lsp/server" import { LSPServer } from "../../src/lsp/server"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { Instance } from "../../src/project/instance"
import { provideTmpdirInstance } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const it = testEffect(Layer.mergeAll(LSP.defaultLayer, CrossSpawnSpawner.defaultLayer)) function withInstance(fn: (dir: string) => Promise<void>) {
return async () => {
await using tmp = await tmpdir()
try {
await Instance.provide({
directory: tmp.path,
fn: () => fn(tmp.path),
})
} finally {
await Instance.disposeAll()
}
}
}
describe("LSP service lifecycle", () => { describe("LSP service lifecycle", () => {
let spawnSpy: ReturnType<typeof spyOn> let spawnSpy: ReturnType<typeof spyOn>
@@ -20,112 +30,97 @@ describe("LSP service lifecycle", () => {
spawnSpy.mockRestore() spawnSpy.mockRestore()
}) })
it.live("init() completes without error", () => provideTmpdirInstance(() => LSP.Service.use((lsp) => lsp.init()))) test(
"init() completes without error",
it.live("status() returns empty array initially", () => withInstance(async () => {
provideTmpdirInstance(() => await Lsp.LSP.init()
LSP.Service.use((lsp) => }),
Effect.gen(function* () {
const result = yield* lsp.status()
expect(Array.isArray(result)).toBe(true)
expect(result.length).toBe(0)
}),
),
),
) )
it.live("diagnostics() returns empty object initially", () => test(
provideTmpdirInstance(() => "status() returns empty array initially",
LSP.Service.use((lsp) => withInstance(async () => {
Effect.gen(function* () { const result = await Lsp.LSP.status()
const result = yield* lsp.diagnostics() expect(Array.isArray(result)).toBe(true)
expect(typeof result).toBe("object") expect(result.length).toBe(0)
expect(Object.keys(result).length).toBe(0) }),
}),
),
),
) )
it.live("hasClients() returns true for .ts files in instance", () => test(
provideTmpdirInstance((dir) => "diagnostics() returns empty object initially",
LSP.Service.use((lsp) => withInstance(async () => {
Effect.gen(function* () { const result = await Lsp.LSP.diagnostics()
const result = yield* lsp.hasClients(path.join(dir, "test.ts")) expect(typeof result).toBe("object")
expect(result).toBe(true) expect(Object.keys(result).length).toBe(0)
}), }),
),
),
) )
it.live("hasClients() returns false for files outside instance", () => test(
provideTmpdirInstance((dir) => "hasClients() returns true for .ts files in instance",
LSP.Service.use((lsp) => withInstance(async (dir) => {
Effect.gen(function* () { const result = await Lsp.LSP.hasClients(path.join(dir, "test.ts"))
const result = yield* lsp.hasClients(path.join(dir, "..", "outside.ts")) expect(result).toBe(true)
expect(typeof result).toBe("boolean") }),
}),
),
),
) )
it.live("workspaceSymbol() returns empty array with no clients", () => test(
provideTmpdirInstance(() => "hasClients() returns false for files outside instance",
LSP.Service.use((lsp) => withInstance(async (dir) => {
Effect.gen(function* () { const result = await Lsp.LSP.hasClients(path.join(dir, "..", "outside.ts"))
const result = yield* lsp.workspaceSymbol("test") // hasClients checks servers but doesn't check containsPath — getClients does
expect(Array.isArray(result)).toBe(true) // So hasClients may return true even for outside files (it checks extension + root)
expect(result.length).toBe(0) // The guard is in getClients, not hasClients
}), expect(typeof result).toBe("boolean")
), }),
),
) )
it.live("definition() returns empty array for unknown file", () => test(
provideTmpdirInstance((dir) => "workspaceSymbol() returns empty array with no clients",
LSP.Service.use((lsp) => withInstance(async () => {
Effect.gen(function* () { const result = await Lsp.LSP.workspaceSymbol("test")
const result = yield* lsp.definition({ expect(Array.isArray(result)).toBe(true)
file: path.join(dir, "nonexistent.ts"), expect(result.length).toBe(0)
line: 0, }),
character: 0,
})
expect(Array.isArray(result)).toBe(true)
}),
),
),
) )
it.live("references() returns empty array for unknown file", () => test(
provideTmpdirInstance((dir) => "definition() returns empty array for unknown file",
LSP.Service.use((lsp) => withInstance(async (dir) => {
Effect.gen(function* () { const result = await Lsp.LSP.definition({
const result = yield* lsp.references({ file: path.join(dir, "nonexistent.ts"),
file: path.join(dir, "nonexistent.ts"), line: 0,
line: 0, character: 0,
character: 0, })
}) expect(Array.isArray(result)).toBe(true)
expect(Array.isArray(result)).toBe(true) }),
}),
),
),
) )
it.live("multiple init() calls are idempotent", () => test(
provideTmpdirInstance(() => "references() returns empty array for unknown file",
LSP.Service.use((lsp) => withInstance(async (dir) => {
Effect.gen(function* () { const result = await Lsp.LSP.references({
yield* lsp.init() file: path.join(dir, "nonexistent.ts"),
yield* lsp.init() line: 0,
yield* lsp.init() character: 0,
}), })
), expect(Array.isArray(result)).toBe(true)
), }),
)
test(
"multiple init() calls are idempotent",
withInstance(async () => {
await Lsp.LSP.init()
await Lsp.LSP.init()
await Lsp.LSP.init()
// Should not throw or create duplicate state
}),
) )
}) })
describe("LSP.Diagnostic", () => { describe("LSP.Diagnostic", () => {
test("pretty() formats error diagnostic", () => { test("pretty() formats error diagnostic", () => {
const result = LSP.Diagnostic.pretty({ const result = Lsp.LSP.Diagnostic.pretty({
range: { start: { line: 9, character: 4 }, end: { line: 9, character: 10 } }, range: { start: { line: 9, character: 4 }, end: { line: 9, character: 10 } },
message: "Type 'string' is not assignable to type 'number'", message: "Type 'string' is not assignable to type 'number'",
severity: 1, severity: 1,
@@ -134,7 +129,7 @@ describe("LSP.Diagnostic", () => {
}) })
test("pretty() formats warning diagnostic", () => { test("pretty() formats warning diagnostic", () => {
const result = LSP.Diagnostic.pretty({ const result = Lsp.LSP.Diagnostic.pretty({
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } }, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } },
message: "Unused variable", message: "Unused variable",
severity: 2, severity: 2,
@@ -143,7 +138,7 @@ describe("LSP.Diagnostic", () => {
}) })
test("pretty() defaults to ERROR when no severity", () => { test("pretty() defaults to ERROR when no severity", () => {
const result = LSP.Diagnostic.pretty({ const result = Lsp.LSP.Diagnostic.pretty({
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } }, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } },
message: "Something wrong", message: "Something wrong",
} as any) } as any)
+20 -45
View File
@@ -1,6 +1,4 @@
import { test, expect, mock, beforeEach } from "bun:test" import { test, expect, mock, beforeEach } from "bun:test"
import { Effect } from "effect"
import type { MCP as MCPNS } from "../../src/mcp/index"
// Track what options were passed to each transport constructor // Track what options were passed to each transport constructor
const transportCalls: Array<{ const transportCalls: Array<{
@@ -46,10 +44,8 @@ beforeEach(() => {
// Import MCP after mocking // Import MCP after mocking
const { MCP } = await import("../../src/mcp/index") const { MCP } = await import("../../src/mcp/index")
const { AppRuntime } = await import("../../src/effect/app-runtime")
const { Instance } = await import("../../src/project/instance") const { Instance } = await import("../../src/project/instance")
const { tmpdir } = await import("../fixture/fixture") const { tmpdir } = await import("../fixture/fixture")
const service = MCP.Service as unknown as Effect.Effect<MCPNS.Interface, never, never>
test("headers are passed to transports when oauth is enabled (default)", async () => { test("headers are passed to transports when oauth is enabled (default)", async () => {
await using tmp = await tmpdir({ await using tmp = await tmpdir({
@@ -77,21 +73,14 @@ test("headers are passed to transports when oauth is enabled (default)", async (
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
// Trigger MCP initialization - it will fail to connect but we can check the transport options // Trigger MCP initialization - it will fail to connect but we can check the transport options
await AppRuntime.runPromise( await MCP.add("test-server", {
Effect.gen(function* () { type: "remote",
const mcp = yield* service url: "https://example.com/mcp",
yield* mcp headers: {
.add("test-server", { Authorization: "Bearer test-token",
type: "remote", "X-Custom-Header": "custom-value",
url: "https://example.com/mcp", },
headers: { }).catch(() => {})
Authorization: "Bearer test-token",
"X-Custom-Header": "custom-value",
},
})
.pipe(Effect.catch(() => Effect.void))
}),
)
// Both transports should have been created with headers // Both transports should have been created with headers
expect(transportCalls.length).toBeGreaterThanOrEqual(1) expect(transportCalls.length).toBeGreaterThanOrEqual(1)
@@ -117,21 +106,14 @@ test("headers are passed to transports when oauth is explicitly disabled", async
fn: async () => { fn: async () => {
transportCalls.length = 0 transportCalls.length = 0
await AppRuntime.runPromise( await MCP.add("test-server-no-oauth", {
Effect.gen(function* () { type: "remote",
const mcp = yield* service url: "https://example.com/mcp",
yield* mcp oauth: false,
.add("test-server-no-oauth", { headers: {
type: "remote", Authorization: "Bearer test-token",
url: "https://example.com/mcp", },
oauth: false, }).catch(() => {})
headers: {
Authorization: "Bearer test-token",
},
})
.pipe(Effect.catch(() => Effect.void))
}),
)
expect(transportCalls.length).toBeGreaterThanOrEqual(1) expect(transportCalls.length).toBeGreaterThanOrEqual(1)
@@ -155,17 +137,10 @@ test("no requestInit when headers are not provided", async () => {
fn: async () => { fn: async () => {
transportCalls.length = 0 transportCalls.length = 0
await AppRuntime.runPromise( await MCP.add("test-server-no-headers", {
Effect.gen(function* () { type: "remote",
const mcp = yield* service url: "https://example.com/mcp",
yield* mcp }).catch(() => {})
.add("test-server-no-headers", {
type: "remote",
url: "https://example.com/mcp",
})
.pipe(Effect.catch(() => Effect.void))
}),
)
expect(transportCalls.length).toBeGreaterThanOrEqual(1) expect(transportCalls.length).toBeGreaterThanOrEqual(1)

Some files were not shown because too many files have changed in this diff Show More