mirror of
https://github.com/anomalyco/opencode.git
synced 2026-03-18 20:54:48 +00:00
Compare commits
80 Commits
refactor/e
...
beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74bc662f76 | ||
|
|
c1a692248a | ||
|
|
20b4f4bd0c | ||
|
|
1cc5615190 | ||
|
|
82629bdcec | ||
|
|
af480c29cf | ||
|
|
760799d825 | ||
|
|
9491293490 | ||
|
|
ce2c9b0182 | ||
|
|
3f1c96a0bb | ||
|
|
3558deba4a | ||
|
|
c3ddc85cca | ||
|
|
a800583aea | ||
|
|
c7b93a885c | ||
|
|
df5f873cb3 | ||
|
|
1412e6d8b1 | ||
|
|
302437bd6a | ||
|
|
0b4ed44fde | ||
|
|
f7eb0c426c | ||
|
|
1392d868b1 | ||
|
|
7910ce5d36 | ||
|
|
6ad171dba9 | ||
|
|
cb5674edc7 | ||
|
|
b99de4118e | ||
|
|
040700dbc4 | ||
|
|
4d5da9697e | ||
|
|
a28648f530 | ||
|
|
4d81e2d4d9 | ||
|
|
21e72cbf42 | ||
|
|
5f277d1e62 | ||
|
|
d67e877e28 | ||
|
|
d4e51e04b3 | ||
|
|
070c1679e4 | ||
|
|
406d216cd2 | ||
|
|
5dc8b4ef29 | ||
|
|
2f41d89163 | ||
|
|
b2eae867a1 | ||
|
|
3c2fda4d91 | ||
|
|
2678ceb45e | ||
|
|
58a4cd00b6 | ||
|
|
0faa191b6d | ||
|
|
58cf092105 | ||
|
|
0ff8bfe1d9 | ||
|
|
ceb79c786a | ||
|
|
b1a15d559b | ||
|
|
124a8abf9b | ||
|
|
85c2bb342b | ||
|
|
4c57e39466 | ||
|
|
0cdd4e4e16 | ||
|
|
a9b01be0c2 | ||
|
|
528daf5490 | ||
|
|
0e176d3ac3 | ||
|
|
27f359852e | ||
|
|
173128d431 | ||
|
|
e8ee1e239f | ||
|
|
656fa191c1 | ||
|
|
e993acec31 | ||
|
|
611e616010 | ||
|
|
b286c0ae3f | ||
|
|
81a61f8dbd | ||
|
|
752e449e38 | ||
|
|
5d419a0211 | ||
|
|
8b168981aa | ||
|
|
724dd665ec | ||
|
|
fc258ea74f | ||
|
|
abd9e195ac | ||
|
|
9d78b69cd3 | ||
|
|
e31f00ad22 | ||
|
|
a90e8de050 | ||
|
|
eabf770053 | ||
|
|
86d7bdc542 | ||
|
|
d3ab78bba0 | ||
|
|
a531f3f36d | ||
|
|
bb3382311d | ||
|
|
ad545d0cc9 | ||
|
|
ac244b1458 | ||
|
|
f202536b65 | ||
|
|
405cc3f610 | ||
|
|
878c1b8c2d | ||
|
|
bb4d978684 |
6
.opencode/.gitignore
vendored
6
.opencode/.gitignore
vendored
@@ -1,4 +1,6 @@
|
||||
plans/
|
||||
bun.lock
|
||||
node_modules
|
||||
plans
|
||||
package.json
|
||||
bun.lock
|
||||
.gitignore
|
||||
package-lock.json
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
/// <reference path="../env.d.ts" />
|
||||
import { tool } from "@opencode-ai/plugin"
|
||||
import DESCRIPTION from "./github-pr-search.txt"
|
||||
|
||||
async function githubFetch(endpoint: string, options: RequestInit = {}) {
|
||||
const response = await fetch(`https://api.github.com${endpoint}`, {
|
||||
@@ -24,7 +23,16 @@ interface PR {
|
||||
}
|
||||
|
||||
export default tool({
|
||||
description: DESCRIPTION,
|
||||
description: `Use this tool to search GitHub pull requests by title and description.
|
||||
|
||||
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
|
||||
- PR number and title
|
||||
- Author
|
||||
- State (open/closed/merged)
|
||||
- Labels
|
||||
- Description snippet
|
||||
|
||||
Use the query parameter to search for keywords that might appear in PR titles or descriptions.`,
|
||||
args: {
|
||||
query: tool.schema.string().describe("Search query for PR titles and descriptions"),
|
||||
limit: tool.schema.number().describe("Maximum number of results to return").default(10),
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
Use this tool to search GitHub pull requests by title and description.
|
||||
|
||||
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
|
||||
- PR number and title
|
||||
- Author
|
||||
- State (open/closed/merged)
|
||||
- Labels
|
||||
- Description snippet
|
||||
|
||||
Use the query parameter to search for keywords that might appear in PR titles or descriptions.
|
||||
@@ -1,6 +1,5 @@
|
||||
/// <reference path="../env.d.ts" />
|
||||
import { tool } from "@opencode-ai/plugin"
|
||||
import DESCRIPTION from "./github-triage.txt"
|
||||
|
||||
const TEAM = {
|
||||
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
|
||||
@@ -40,7 +39,12 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
|
||||
}
|
||||
|
||||
export default tool({
|
||||
description: DESCRIPTION,
|
||||
description: `Use this tool to assign and/or label a GitHub issue.
|
||||
|
||||
Choose labels and assignee using the current triage policy and ownership rules.
|
||||
Pick the most fitting labels for the issue and assign one owner.
|
||||
|
||||
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.`,
|
||||
args: {
|
||||
assignee: tool.schema
|
||||
.enum(ASSIGNEES as [string, ...string[]])
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
Use this tool to assign and/or label a GitHub issue.
|
||||
|
||||
Choose labels and assignee using the current triage policy and ownership rules.
|
||||
Pick the most fitting labels for the issue and assign one owner.
|
||||
|
||||
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.
|
||||
@@ -128,7 +128,7 @@ If you are working on a project that's related to OpenCode and is using "opencod
|
||||
|
||||
#### How is this different from Claude Code?
|
||||
|
||||
It's very similar to Claude Code in terms of capability. Here are the key differences:
|
||||
It's very similar to Claude Code in terms of capability. Here are the key differences::
|
||||
|
||||
- 100% open source
|
||||
- Not coupled to any provider. Although we recommend the models we provide through [OpenCode Zen](https://opencode.ai/zen), OpenCode can be used with Claude, OpenAI, Google, or even local models. As models evolve, the gaps between them will close and pricing will drop, so being provider-agnostic is important.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-VF3rXpIz9XbTTfM8YB98DJJOs4Sotaq5cSwIBUfbNDA=",
|
||||
"aarch64-linux": "sha256-cIE10+0xhb5u0TQedaDbEu6e40ypHnSBmh8unnhCDZE=",
|
||||
"aarch64-darwin": "sha256-d/l7g/4angRw/oxoSGpcYL0i9pNphgRChJwhva5Kypo=",
|
||||
"x86_64-darwin": "sha256-WQyuUKMfHpO1rpWsjhCXuG99iX2jEdSe3AVltxvt+1Y="
|
||||
"x86_64-linux": "sha256-TnrYykX8Mf/Ugtkix6V",
|
||||
"aarch64-linux": "sha256-TnrYykX8Mf/Ugtkix6V",
|
||||
"aarch64-darwin": "sha256-TnrYykX8Mf/Ugtkix6V",
|
||||
"x86_64-darwin": "sha256-TnrYykX8Mf/Ugtkix6V"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message } from "@opencode-ai/sdk/v2/client"
|
||||
import { findAssistantMessages } from "@opencode-ai/ui/find-assistant-messages"
|
||||
|
||||
function user(id: string): Message {
|
||||
return {
|
||||
id,
|
||||
role: "user",
|
||||
sessionID: "session-1",
|
||||
time: { created: 1 },
|
||||
} as unknown as Message
|
||||
}
|
||||
|
||||
function assistant(id: string, parentID: string): Message {
|
||||
return {
|
||||
id,
|
||||
role: "assistant",
|
||||
sessionID: "session-1",
|
||||
parentID,
|
||||
time: { created: 1 },
|
||||
} as unknown as Message
|
||||
}
|
||||
|
||||
describe("findAssistantMessages", () => {
|
||||
test("normal ordering: assistant after user in array → found via forward scan", () => {
|
||||
const messages = [user("u1"), assistant("a1", "u1")]
|
||||
const result = findAssistantMessages(messages, 0, "u1")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("a1")
|
||||
})
|
||||
|
||||
test("clock skew: assistant before user in array → found via backward scan", () => {
|
||||
// When client clock is ahead, user ID sorts after assistant ID,
|
||||
// so assistant appears earlier in the ID-sorted message array
|
||||
const messages = [assistant("a1", "u1"), user("u1")]
|
||||
const result = findAssistantMessages(messages, 1, "u1")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("a1")
|
||||
})
|
||||
|
||||
test("no assistant messages → returns empty array", () => {
|
||||
const messages = [user("u1"), user("u2")]
|
||||
const result = findAssistantMessages(messages, 0, "u1")
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("multiple assistant messages with matching parentID → all found", () => {
|
||||
const messages = [user("u1"), assistant("a1", "u1"), assistant("a2", "u1")]
|
||||
const result = findAssistantMessages(messages, 0, "u1")
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0].id).toBe("a1")
|
||||
expect(result[1].id).toBe("a2")
|
||||
})
|
||||
|
||||
test("does not return assistant messages with different parentID", () => {
|
||||
const messages = [user("u1"), assistant("a1", "u1"), assistant("a2", "other")]
|
||||
const result = findAssistantMessages(messages, 0, "u1")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("a1")
|
||||
})
|
||||
|
||||
test("stops forward scan at next user message", () => {
|
||||
const messages = [user("u1"), assistant("a1", "u1"), user("u2"), assistant("a2", "u1")]
|
||||
const result = findAssistantMessages(messages, 0, "u1")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("a1")
|
||||
})
|
||||
|
||||
test("stops backward scan at previous user message", () => {
|
||||
const messages = [assistant("a0", "u1"), user("u0"), assistant("a1", "u1"), user("u1")]
|
||||
const result = findAssistantMessages(messages, 3, "u1")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("a1")
|
||||
})
|
||||
|
||||
test("invalid index returns empty array", () => {
|
||||
const messages = [user("u1")]
|
||||
expect(findAssistantMessages(messages, -1, "u1")).toHaveLength(0)
|
||||
expect(findAssistantMessages(messages, 5, "u1")).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -287,6 +287,9 @@ export function Titlebar() {
|
||||
</div>
|
||||
</div>
|
||||
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
|
||||
<div class="bg-icon-interactive-base text-background-base font-medium px-2 rounded-sm uppercase font-mono">
|
||||
BETA
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex items-center justify-center pointer-events-none">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createStore } from "solid-js/store"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -25,6 +26,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
customOn: cached?.customOn ?? ([] as boolean[]),
|
||||
editing: false,
|
||||
sending: false,
|
||||
collapsed: false,
|
||||
})
|
||||
|
||||
let root: HTMLDivElement | undefined
|
||||
@@ -35,6 +37,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
const input = createMemo(() => store.custom[store.tab] ?? "")
|
||||
const on = createMemo(() => store.customOn[store.tab] === true)
|
||||
const multi = createMemo(() => question()?.multiple === true)
|
||||
const picked = createMemo(() => store.answers[store.tab]?.length ?? 0)
|
||||
|
||||
const summary = createMemo(() => {
|
||||
const n = Math.min(store.tab + 1, total())
|
||||
@@ -43,6 +46,8 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
|
||||
const last = createMemo(() => store.tab >= total() - 1)
|
||||
|
||||
const fold = () => setStore("collapsed", (value) => !value)
|
||||
|
||||
const customUpdate = (value: string, selected: boolean = on()) => {
|
||||
const prev = input().trim()
|
||||
const next = value.trim()
|
||||
@@ -257,9 +262,21 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
kind="question"
|
||||
ref={(el) => (root = el)}
|
||||
header={
|
||||
<>
|
||||
<div
|
||||
data-action="session-question-toggle"
|
||||
class="flex flex-1 min-w-0 items-center gap-2 cursor-default select-none"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
style={{ margin: "0 -10px", padding: "0 0 0 10px" }}
|
||||
onClick={fold}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return
|
||||
event.preventDefault()
|
||||
fold()
|
||||
}}
|
||||
>
|
||||
<div data-slot="question-header-title">{summary()}</div>
|
||||
<div data-slot="question-progress">
|
||||
<div data-slot="question-progress" class="ml-auto mr-1">
|
||||
<For each={questions()}>
|
||||
{(_, i) => (
|
||||
<button
|
||||
@@ -271,13 +288,38 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
(store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
|
||||
}
|
||||
disabled={store.sending}
|
||||
onClick={() => jump(i())}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
jump(i())
|
||||
}}
|
||||
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</>
|
||||
<div>
|
||||
<IconButton
|
||||
data-action="session-question-toggle-button"
|
||||
icon="chevron-down"
|
||||
size="normal"
|
||||
variant="ghost"
|
||||
classList={{ "rotate-180": store.collapsed }}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
fold()
|
||||
}}
|
||||
aria-label={store.collapsed ? language.t("session.todo.expand") : language.t("session.todo.collapse")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
@@ -297,56 +339,121 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div data-slot="question-text">{question()?.question}</div>
|
||||
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
|
||||
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
|
||||
<div
|
||||
data-slot="question-text"
|
||||
class="cursor-default"
|
||||
classList={{
|
||||
"mb-6": store.collapsed && picked() === 0,
|
||||
}}
|
||||
role={store.collapsed ? "button" : undefined}
|
||||
tabIndex={store.collapsed ? 0 : undefined}
|
||||
onClick={fold}
|
||||
onKeyDown={(event) => {
|
||||
if (!store.collapsed) return
|
||||
if (event.key !== "Enter" && event.key !== " ") return
|
||||
event.preventDefault()
|
||||
fold()
|
||||
}}
|
||||
>
|
||||
{question()?.question}
|
||||
</div>
|
||||
<Show when={store.collapsed && picked() > 0}>
|
||||
<div data-slot="question-hint" class="cursor-default mb-6">
|
||||
{picked()} answer{picked() === 1 ? "" : "s"} selected
|
||||
</div>
|
||||
</Show>
|
||||
<div data-slot="question-options">
|
||||
<For each={options()}>
|
||||
{(opt, i) => {
|
||||
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
|
||||
return (
|
||||
<div data-slot="question-answers" hidden={store.collapsed} aria-hidden={store.collapsed}>
|
||||
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
|
||||
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
|
||||
</Show>
|
||||
<div data-slot="question-options">
|
||||
<For each={options()}>
|
||||
{(opt, i) => {
|
||||
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
|
||||
return (
|
||||
<button
|
||||
data-slot="question-option"
|
||||
data-picked={picked()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={picked()}
|
||||
disabled={store.sending}
|
||||
onClick={() => selectOption(i())}
|
||||
>
|
||||
<span data-slot="question-option-check" aria-hidden="true">
|
||||
<span
|
||||
data-slot="question-option-box"
|
||||
data-type={multi() ? "checkbox" : "radio"}
|
||||
data-picked={picked()}
|
||||
>
|
||||
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
|
||||
<Icon name="check-small" size="small" />
|
||||
</Show>
|
||||
</span>
|
||||
</span>
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{opt.label}</span>
|
||||
<Show when={opt.description}>
|
||||
<span data-slot="option-description">{opt.description}</span>
|
||||
</Show>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
<Show
|
||||
when={store.editing}
|
||||
fallback={
|
||||
<button
|
||||
data-slot="question-option"
|
||||
data-picked={picked()}
|
||||
data-custom="true"
|
||||
data-picked={on()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={picked()}
|
||||
aria-checked={on()}
|
||||
disabled={store.sending}
|
||||
onClick={() => selectOption(i())}
|
||||
onClick={customOpen}
|
||||
>
|
||||
<span data-slot="question-option-check" aria-hidden="true">
|
||||
<span
|
||||
data-slot="question-option-box"
|
||||
data-type={multi() ? "checkbox" : "radio"}
|
||||
data-picked={picked()}
|
||||
>
|
||||
<span
|
||||
data-slot="question-option-check"
|
||||
aria-hidden="true"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
customToggle()
|
||||
}}
|
||||
>
|
||||
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
|
||||
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
|
||||
<Icon name="check-small" size="small" />
|
||||
</Show>
|
||||
</span>
|
||||
</span>
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{opt.label}</span>
|
||||
<Show when={opt.description}>
|
||||
<span data-slot="option-description">{opt.description}</span>
|
||||
</Show>
|
||||
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
|
||||
<span data-slot="option-description">{input() || language.t("ui.question.custom.placeholder")}</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
<Show
|
||||
when={store.editing}
|
||||
fallback={
|
||||
<button
|
||||
}
|
||||
>
|
||||
<form
|
||||
data-slot="question-option"
|
||||
data-custom="true"
|
||||
data-picked={on()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={on()}
|
||||
disabled={store.sending}
|
||||
onClick={customOpen}
|
||||
onMouseDown={(e) => {
|
||||
if (store.sending) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (e.target instanceof HTMLTextAreaElement) return
|
||||
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
|
||||
if (input instanceof HTMLTextAreaElement) input.focus()
|
||||
}}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
}}
|
||||
>
|
||||
<span
|
||||
data-slot="question-option-check"
|
||||
@@ -365,80 +472,39 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
</span>
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
|
||||
<span data-slot="option-description">{input() || language.t("ui.question.custom.placeholder")}</span>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<form
|
||||
data-slot="question-option"
|
||||
data-custom="true"
|
||||
data-picked={on()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={on()}
|
||||
onMouseDown={(e) => {
|
||||
if (store.sending) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (e.target instanceof HTMLTextAreaElement) return
|
||||
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
|
||||
if (input instanceof HTMLTextAreaElement) input.focus()
|
||||
}}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
}}
|
||||
>
|
||||
<span
|
||||
data-slot="question-option-check"
|
||||
aria-hidden="true"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
customToggle()
|
||||
}}
|
||||
>
|
||||
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
|
||||
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
|
||||
<Icon name="check-small" size="small" />
|
||||
</Show>
|
||||
</span>
|
||||
</span>
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
|
||||
<textarea
|
||||
ref={(el) =>
|
||||
setTimeout(() => {
|
||||
el.focus()
|
||||
el.style.height = "0px"
|
||||
el.style.height = `${el.scrollHeight}px`
|
||||
}, 0)
|
||||
}
|
||||
data-slot="question-custom-input"
|
||||
placeholder={language.t("ui.question.custom.placeholder")}
|
||||
value={input()}
|
||||
rows={1}
|
||||
disabled={store.sending}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
setStore("editing", false)
|
||||
return
|
||||
<textarea
|
||||
ref={(el) =>
|
||||
setTimeout(() => {
|
||||
el.focus()
|
||||
el.style.height = "0px"
|
||||
el.style.height = `${el.scrollHeight}px`
|
||||
}, 0)
|
||||
}
|
||||
if (e.key !== "Enter" || e.shiftKey) return
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
}}
|
||||
onInput={(e) => {
|
||||
customUpdate(e.currentTarget.value)
|
||||
e.currentTarget.style.height = "0px"
|
||||
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</form>
|
||||
</Show>
|
||||
data-slot="question-custom-input"
|
||||
placeholder={language.t("ui.question.custom.placeholder")}
|
||||
value={input()}
|
||||
rows={1}
|
||||
disabled={store.sending}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
setStore("editing", false)
|
||||
return
|
||||
}
|
||||
if (e.key !== "Enter" || e.shiftKey) return
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
}}
|
||||
onInput={(e) => {
|
||||
customUpdate(e.currentTarget.value)
|
||||
e.currentTarget.style.height = "0px"
|
||||
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</form>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</DockPrompt>
|
||||
)
|
||||
|
||||
@@ -249,7 +249,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | نماذج برمجة منخفضة التكلفة للجميع",
|
||||
"go.meta.description":
|
||||
"يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود طلب سخية لمدة 5 ساعات لـ GLM-5 و Kimi K2.5 و MiniMax M2.5.",
|
||||
"يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود طلب سخية لمدة 5 ساعات لـ GLM-5 و Kimi K2.5 و MiniMax M2.5 وMiniMax M2.7.",
|
||||
"go.hero.title": "نماذج برمجة منخفضة التكلفة للجميع",
|
||||
"go.hero.body":
|
||||
"يجلب Go البرمجة الوكيلة للمبرمجين حول العالم. يوفر حدودًا سخية ووصولًا موثوقًا إلى أقوى النماذج مفتوحة المصدر، حتى تتمكن من البناء باستخدام وكلاء أقوياء دون القلق بشأن التكلفة أو التوفر.",
|
||||
@@ -297,7 +297,7 @@ export const dict = {
|
||||
"go.problem.item1": "أسعار اشتراك منخفضة التكلفة",
|
||||
"go.problem.item2": "حدود سخية ووصول موثوق",
|
||||
"go.problem.item3": "مصمم لأكبر عدد ممكن من المبرمجين",
|
||||
"go.problem.item4": "يتضمن GLM-5 وKimi K2.5 وMiniMax M2.5",
|
||||
"go.problem.item4": "يتضمن GLM-5 وKimi K2.5 وMiniMax M2.5 وMiniMax M2.7",
|
||||
"go.how.title": "كيف يعمل Go",
|
||||
"go.how.body": "يبدأ Go من $5 للشهر الأول، ثم $10/شهر. يمكنك استخدامه مع OpenCode أو أي وكيل.",
|
||||
"go.how.step1.title": "أنشئ حسابًا",
|
||||
@@ -318,10 +318,10 @@ export const dict = {
|
||||
"go.faq.q1": "ما هو OpenCode Go؟",
|
||||
"go.faq.a1": "Go هو اشتراك منخفض التكلفة يمنحك وصولًا موثوقًا إلى نماذج مفتوحة المصدر قادرة على البرمجة الوكيلة.",
|
||||
"go.faq.q2": "ما النماذج التي يتضمنها Go؟",
|
||||
"go.faq.a2": "يتضمن Go نماذج GLM-5 وKimi K2.5 وMiniMax M2.5، مع حدود سخية ووصول موثوق.",
|
||||
"go.faq.a2": "يتضمن Go نماذج GLM-5 وKimi K2.5 وMiniMax M2.5 وMiniMax M2.7، مع حدود سخية ووصول موثوق.",
|
||||
"go.faq.q3": "هل Go هو نفسه Zen؟",
|
||||
"go.faq.a3":
|
||||
"لا. Zen هو الدفع حسب الاستخدام، بينما يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود سخية ووصول موثوق إلى نماذج المصدر المفتوح GLM-5 و Kimi K2.5 و MiniMax M2.5.",
|
||||
"لا. Zen هو الدفع حسب الاستخدام، بينما يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود سخية ووصول موثوق إلى نماذج المصدر المفتوح GLM-5 و Kimi K2.5 و MiniMax M2.5 وMiniMax M2.7.",
|
||||
"go.faq.q4": "كم تكلفة Go؟",
|
||||
"go.faq.a4.p1.beforePricing": "تكلفة Go",
|
||||
"go.faq.a4.p1.pricingLink": "$5 للشهر الأول",
|
||||
@@ -345,7 +345,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "ما الفرق بين النماذج المجانية وGo؟",
|
||||
"go.faq.a9":
|
||||
"تشمل النماذج المجانية Big Pickle بالإضافة إلى النماذج الترويجية المتاحة في ذلك الوقت، مع حصة 200 طلب/يوم. يتضمن Go نماذج GLM-5 وKimi K2.5 وMiniMax M2.5 مع حصص طلبات أعلى مطبقة عبر نوافذ متجددة (5 ساعات، أسبوعيًا، وشهريًا)، تعادل تقريبًا 12 دولارًا كل 5 ساعات، و30 دولارًا في الأسبوع، و60 دولارًا في الشهر (تختلف أعداد الطلبات الفعلية حسب النموذج والاستخدام).",
|
||||
"تشمل النماذج المجانية Big Pickle بالإضافة إلى النماذج الترويجية المتاحة في ذلك الوقت، مع حصة 200 طلب/يوم. يتضمن Go نماذج GLM-5 وKimi K2.5 وMiniMax M2.5 وMiniMax M2.7 مع حصص طلبات أعلى مطبقة عبر نوافذ متجددة (5 ساعات، أسبوعيًا، وشهريًا)، تعادل تقريبًا 12 دولارًا كل 5 ساعات، و30 دولارًا في الأسبوع، و60 دولارًا في الشهر (تختلف أعداد الطلبات الفعلية حسب النموذج والاستخدام).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "تم تجاوز حد الطلبات. يرجى المحاولة مرة أخرى لاحقًا.",
|
||||
"zen.api.error.modelNotSupported": "النموذج {{model}} غير مدعوم",
|
||||
|
||||
@@ -253,7 +253,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | Modelos de codificação de baixo custo para todos",
|
||||
"go.meta.description":
|
||||
"O Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos de solicitação de 5 horas para GLM-5, Kimi K2.5 e MiniMax M2.5.",
|
||||
"O Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos de solicitação de 5 horas para GLM-5, Kimi K2.5, MiniMax M2.5 e MiniMax M2.7.",
|
||||
"go.hero.title": "Modelos de codificação de baixo custo para todos",
|
||||
"go.hero.body":
|
||||
"O Go traz a codificação com agentes para programadores em todo o mundo. Oferecendo limites generosos e acesso confiável aos modelos de código aberto mais capazes, para que você possa construir com agentes poderosos sem se preocupar com custos ou disponibilidade.",
|
||||
@@ -302,7 +302,7 @@ export const dict = {
|
||||
"go.problem.item1": "Preço de assinatura de baixo custo",
|
||||
"go.problem.item2": "Limites generosos e acesso confiável",
|
||||
"go.problem.item3": "Feito para o maior número possível de programadores",
|
||||
"go.problem.item4": "Inclui GLM-5, Kimi K2.5 e MiniMax M2.5",
|
||||
"go.problem.item4": "Inclui GLM-5, Kimi K2.5, MiniMax M2.5 e MiniMax M2.7",
|
||||
"go.how.title": "Como o Go funciona",
|
||||
"go.how.body":
|
||||
"O Go começa em $5 no primeiro mês, depois $10/mês. Você pode usá-lo com o OpenCode ou qualquer agente.",
|
||||
@@ -325,10 +325,10 @@ export const dict = {
|
||||
"go.faq.a1":
|
||||
"Go é uma assinatura de baixo custo que oferece acesso confiável a modelos de código aberto capazes para codificação com agentes.",
|
||||
"go.faq.q2": "Quais modelos o Go inclui?",
|
||||
"go.faq.a2": "Go inclui GLM-5, Kimi K2.5 e MiniMax M2.5, com limites generosos e acesso confiável.",
|
||||
"go.faq.a2": "Go inclui GLM-5, Kimi K2.5, MiniMax M2.5 e MiniMax M2.7, com limites generosos e acesso confiável.",
|
||||
"go.faq.q3": "O Go é o mesmo que o Zen?",
|
||||
"go.faq.a3":
|
||||
"Não. Zen é pay-as-you-go, enquanto o Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos e acesso confiável aos modelos open source GLM-5, Kimi K2.5 e MiniMax M2.5.",
|
||||
"Não. Zen é pay-as-you-go, enquanto o Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos e acesso confiável aos modelos open source GLM-5, Kimi K2.5, MiniMax M2.5 e MiniMax M2.7.",
|
||||
"go.faq.q4": "Quanto custa o Go?",
|
||||
"go.faq.a4.p1.beforePricing": "O Go custa",
|
||||
"go.faq.a4.p1.pricingLink": "$5 no primeiro mês",
|
||||
@@ -353,7 +353,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "Qual a diferença entre os modelos gratuitos e o Go?",
|
||||
"go.faq.a9":
|
||||
"Os modelos gratuitos incluem Big Pickle e modelos promocionais disponíveis no momento, com uma cota de 200 requisições/dia. O Go inclui GLM-5, Kimi K2.5 e MiniMax M2.5 com cotas de requisição mais altas aplicadas em janelas móveis (5 horas, semanal e mensal), aproximadamente equivalentes a $12 por 5 horas, $30 por semana e $60 por mês (as contagens reais de requisições variam de acordo com o modelo e o uso).",
|
||||
"Os modelos gratuitos incluem Big Pickle e modelos promocionais disponíveis no momento, com uma cota de 200 requisições/dia. O Go inclui GLM-5, Kimi K2.5, MiniMax M2.5 e MiniMax M2.7 com cotas de requisição mais altas aplicadas em janelas móveis (5 horas, semanal e mensal), aproximadamente equivalentes a $12 por 5 horas, $30 por semana e $60 por mês (as contagens reais de requisições variam de acordo com o modelo e o uso).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "Limite de taxa excedido. Por favor, tente novamente mais tarde.",
|
||||
"zen.api.error.modelNotSupported": "Modelo {{model}} não suportado",
|
||||
|
||||
@@ -251,7 +251,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | Kodningsmodeller til lav pris for alle",
|
||||
"go.meta.description":
|
||||
"Go starter ved $5 for den første måned, derefter $10/måned, med generøse 5-timers anmodningsgrænser for GLM-5, Kimi K2.5 og MiniMax M2.5.",
|
||||
"Go starter ved $5 for den første måned, derefter $10/måned, med generøse 5-timers anmodningsgrænser for GLM-5, Kimi K2.5, MiniMax M2.5 og MiniMax M2.7.",
|
||||
"go.hero.title": "Kodningsmodeller til lav pris for alle",
|
||||
"go.hero.body":
|
||||
"Go bringer agentisk kodning til programmører over hele verden. Med generøse grænser og pålidelig adgang til de mest kapable open source-modeller, så du kan bygge med kraftfulde agenter uden at bekymre dig om omkostninger eller tilgængelighed.",
|
||||
@@ -299,7 +299,7 @@ export const dict = {
|
||||
"go.problem.item1": "Lavpris abonnementspriser",
|
||||
"go.problem.item2": "Generøse grænser og pålidelig adgang",
|
||||
"go.problem.item3": "Bygget til så mange programmører som muligt",
|
||||
"go.problem.item4": "Inkluderer GLM-5, Kimi K2.5 og MiniMax M2.5",
|
||||
"go.problem.item4": "Inkluderer GLM-5, Kimi K2.5, MiniMax M2.5 og MiniMax M2.7",
|
||||
"go.how.title": "Hvordan Go virker",
|
||||
"go.how.body":
|
||||
"Go starter ved $5 for den første måned, derefter $10/måned. Du kan bruge det med OpenCode eller enhver agent.",
|
||||
@@ -322,10 +322,11 @@ export const dict = {
|
||||
"go.faq.a1":
|
||||
"Go er et lavprisabonnement, der giver dig pålidelig adgang til kapable open source-modeller til agentisk kodning.",
|
||||
"go.faq.q2": "Hvilke modeller inkluderer Go?",
|
||||
"go.faq.a2": "Go inkluderer GLM-5, Kimi K2.5 og MiniMax M2.5, med generøse grænser og pålidelig adgang.",
|
||||
"go.faq.a2":
|
||||
"Go inkluderer GLM-5, Kimi K2.5, MiniMax M2.5 og MiniMax M2.7, med generøse grænser og pålidelig adgang.",
|
||||
"go.faq.q3": "Er Go det samme som Zen?",
|
||||
"go.faq.a3":
|
||||
"Nej. Zen er pay-as-you-go, mens Go starter ved $5 for den første måned, derefter $10/måned, med generøse grænser og pålidelig adgang til open source-modellerne GLM-5, Kimi K2.5 og MiniMax M2.5.",
|
||||
"Nej. Zen er pay-as-you-go, mens Go starter ved $5 for den første måned, derefter $10/måned, med generøse grænser og pålidelig adgang til open source-modellerne GLM-5, Kimi K2.5, MiniMax M2.5 og MiniMax M2.7.",
|
||||
"go.faq.q4": "Hvad koster Go?",
|
||||
"go.faq.a4.p1.beforePricing": "Go koster",
|
||||
"go.faq.a4.p1.pricingLink": "$5 første måned",
|
||||
@@ -349,7 +350,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "Hvad er forskellen på gratis modeller og Go?",
|
||||
"go.faq.a9":
|
||||
"Gratis modeller inkluderer Big Pickle plus salgsfremmende modeller tilgængelige på det tidspunkt, med en kvote på 200 forespørgsler/dag. Go inkluderer GLM-5, Kimi K2.5 og MiniMax M2.5 med højere anmodningskvoter håndhævet over rullende vinduer (5-timers, ugentlig og månedlig), nogenlunde svarende til $12 pr. 5 timer, $30 pr. uge og $60 pr. måned (faktiske anmodningstal varierer efter model og brug).",
|
||||
"Gratis modeller inkluderer Big Pickle plus salgsfremmende modeller tilgængelige på det tidspunkt, med en kvote på 200 forespørgsler/dag. Go inkluderer GLM-5, Kimi K2.5, MiniMax M2.5 og MiniMax M2.7 med højere anmodningskvoter håndhævet over rullende vinduer (5-timers, ugentlig og månedlig), nogenlunde svarende til $12 pr. 5 timer, $30 pr. uge og $60 pr. måned (faktiske anmodningstal varierer efter model og brug).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "Hastighedsgrænse overskredet. Prøv venligst igen senere.",
|
||||
"zen.api.error.modelNotSupported": "Model {{model}} understøttes ikke",
|
||||
|
||||
@@ -253,7 +253,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | Kostengünstige Coding-Modelle für alle",
|
||||
"go.meta.description":
|
||||
"Go beginnt bei $5 für den ersten Monat, danach $10/Monat, mit großzügigen 5-Stunden-Anfragelimits für GLM-5, Kimi K2.5 und MiniMax M2.5.",
|
||||
"Go beginnt bei $5 für den ersten Monat, danach $10/Monat, mit großzügigen 5-Stunden-Anfragelimits für GLM-5, Kimi K2.5, MiniMax M2.5 und MiniMax M2.7.",
|
||||
"go.hero.title": "Kostengünstige Coding-Modelle für alle",
|
||||
"go.hero.body":
|
||||
"Go bringt Agentic Coding zu Programmierern auf der ganzen Welt. Mit großzügigen Limits und zuverlässigem Zugang zu den leistungsfähigsten Open-Source-Modellen, damit du mit leistungsstarken Agenten entwickeln kannst, ohne dir Gedanken über Kosten oder Verfügbarkeit zu machen.",
|
||||
@@ -301,7 +301,7 @@ export const dict = {
|
||||
"go.problem.item1": "Kostengünstiges Abonnement",
|
||||
"go.problem.item2": "Großzügige Limits und zuverlässiger Zugang",
|
||||
"go.problem.item3": "Für so viele Programmierer wie möglich gebaut",
|
||||
"go.problem.item4": "Beinhaltet GLM-5, Kimi K2.5 und MiniMax M2.5",
|
||||
"go.problem.item4": "Beinhaltet GLM-5, Kimi K2.5, MiniMax M2.5 und MiniMax M2.7",
|
||||
"go.how.title": "Wie Go funktioniert",
|
||||
"go.how.body":
|
||||
"Go beginnt bei $5 für den ersten Monat, danach $10/Monat. Du kannst es mit OpenCode oder jedem Agenten nutzen.",
|
||||
@@ -324,10 +324,11 @@ export const dict = {
|
||||
"go.faq.a1":
|
||||
"Go ist ein kostengünstiges Abonnement, das dir zuverlässigen Zugang zu leistungsfähigen Open-Source-Modellen für Agentic Coding bietet.",
|
||||
"go.faq.q2": "Welche Modelle beinhaltet Go?",
|
||||
"go.faq.a2": "Go beinhaltet GLM-5, Kimi K2.5 und MiniMax M2.5, mit großzügigen Limits und zuverlässigem Zugang.",
|
||||
"go.faq.a2":
|
||||
"Go beinhaltet GLM-5, Kimi K2.5, MiniMax M2.5 und MiniMax M2.7, mit großzügigen Limits und zuverlässigem Zugang.",
|
||||
"go.faq.q3": "Ist Go dasselbe wie Zen?",
|
||||
"go.faq.a3":
|
||||
"Nein. Zen ist Pay-as-you-go, während Go bei $5 für den ersten Monat beginnt, danach $10/Monat, mit großzügigen Limits und zuverlässigem Zugang zu den Open-Source-Modellen GLM-5, Kimi K2.5 und MiniMax M2.5.",
|
||||
"Nein. Zen ist Pay-as-you-go, während Go bei $5 für den ersten Monat beginnt, danach $10/Monat, mit großzügigen Limits und zuverlässigem Zugang zu den Open-Source-Modellen GLM-5, Kimi K2.5, MiniMax M2.5 und MiniMax M2.7.",
|
||||
"go.faq.q4": "Wie viel kostet Go?",
|
||||
"go.faq.a4.p1.beforePricing": "Go kostet",
|
||||
"go.faq.a4.p1.pricingLink": "$5 im ersten Monat",
|
||||
@@ -352,7 +353,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "Was ist der Unterschied zwischen kostenlosen Modellen und Go?",
|
||||
"go.faq.a9":
|
||||
"Kostenlose Modelle beinhalten Big Pickle sowie Werbemodelle, die zum jeweiligen Zeitpunkt verfügbar sind, mit einem Kontingent von 200 Anfragen/Tag. Go beinhaltet GLM-5, Kimi K2.5 und MiniMax M2.5 mit höheren Anfragekontingenten, die über rollierende Zeitfenster (5 Stunden, wöchentlich und monatlich) durchgesetzt werden, grob äquivalent zu $12 pro 5 Stunden, $30 pro Woche und $60 pro Monat (tatsächliche Anfragezahlen variieren je nach Modell und Nutzung).",
|
||||
"Kostenlose Modelle beinhalten Big Pickle sowie Werbemodelle, die zum jeweiligen Zeitpunkt verfügbar sind, mit einem Kontingent von 200 Anfragen/Tag. Go beinhaltet GLM-5, Kimi K2.5, MiniMax M2.5 und MiniMax M2.7 mit höheren Anfragekontingenten, die über rollierende Zeitfenster (5 Stunden, wöchentlich und monatlich) durchgesetzt werden, grob äquivalent zu $12 pro 5 Stunden, $30 pro Woche und $60 pro Monat (tatsächliche Anfragezahlen variieren je nach Modell und Nutzung).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "Ratenlimit überschritten. Bitte versuche es später erneut.",
|
||||
"zen.api.error.modelNotSupported": "Modell {{model}} wird nicht unterstützt",
|
||||
|
||||
@@ -248,7 +248,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | Low cost coding models for everyone",
|
||||
"go.meta.description":
|
||||
"Go starts at $5 for your first month, then $10/month, with generous 5-hour request limits for GLM-5, Kimi K2.5, and MiniMax M2.5.",
|
||||
"Go starts at $5 for your first month, then $10/month, with generous 5-hour request limits for GLM-5, Kimi K2.5, MiniMax M2.5, and MiniMax M2.7.",
|
||||
"go.hero.title": "Low cost coding models for everyone",
|
||||
"go.hero.body":
|
||||
"Go brings agentic coding to programmers around the world. Offering generous limits and reliable access to the most capable open-source models, so you can build with powerful agents without worrying about cost or availability.",
|
||||
@@ -295,7 +295,7 @@ export const dict = {
|
||||
"go.problem.item1": "Low cost subscription pricing",
|
||||
"go.problem.item2": "Generous limits and reliable access",
|
||||
"go.problem.item3": "Built for as many programmers as possible",
|
||||
"go.problem.item4": "Includes GLM-5, Kimi K2.5, and MiniMax M2.5",
|
||||
"go.problem.item4": "Includes GLM-5, Kimi K2.5, MiniMax M2.5, and MiniMax M2.7",
|
||||
"go.how.title": "How Go works",
|
||||
"go.how.body": "Go starts at $5 for your first month, then $10/month. You can use it with OpenCode or any agent.",
|
||||
"go.how.step1.title": "Create an account",
|
||||
@@ -317,10 +317,11 @@ export const dict = {
|
||||
"go.faq.a1":
|
||||
"Go is a low-cost subscription that gives you reliable access to capable open-source models for agentic coding.",
|
||||
"go.faq.q2": "What models does Go include?",
|
||||
"go.faq.a2": "Go includes GLM-5, Kimi K2.5, and MiniMax M2.5, with generous limits and reliable access.",
|
||||
"go.faq.a2":
|
||||
"Go includes GLM-5, Kimi K2.5, MiniMax M2.5, and MiniMax M2.7, with generous limits and reliable access.",
|
||||
"go.faq.q3": "Is Go the same as Zen?",
|
||||
"go.faq.a3":
|
||||
"No. Zen is pay-as-you-go, while Go starts at $5 for your first month, then $10/month, with generous limits and reliable access to open-source models GLM-5, Kimi K2.5, and MiniMax M2.5.",
|
||||
"No. Zen is pay-as-you-go, while Go starts at $5 for your first month, then $10/month, with generous limits and reliable access to open-source models GLM-5, Kimi K2.5, MiniMax M2.5, and MiniMax M2.7.",
|
||||
"go.faq.q4": "How much does Go cost?",
|
||||
"go.faq.a4.p1.beforePricing": "Go costs",
|
||||
"go.faq.a4.p1.pricingLink": "$5 first month",
|
||||
@@ -344,7 +345,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "What is the difference between free models and Go?",
|
||||
"go.faq.a9":
|
||||
"Free models include Big Pickle plus promotional models available at the time, with a quota of 200 requests/day. Go includes GLM-5, Kimi K2.5, and MiniMax M2.5 with higher request quotas enforced across rolling windows (5-hour, weekly, and monthly), roughly equivalent to $12 per 5 hours, $30 per week, and $60 per month (actual request counts vary by model and usage).",
|
||||
"Free models include Big Pickle plus promotional models available at the time, with a quota of 200 requests/day. Go includes GLM-5, Kimi K2.5, MiniMax M2.5, and MiniMax M2.7 with higher request quotas enforced across rolling windows (5-hour, weekly, and monthly), roughly equivalent to $12 per 5 hours, $30 per week, and $60 per month (actual request counts vary by model and usage).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "Rate limit exceeded. Please try again later.",
|
||||
"zen.api.error.modelNotSupported": "Model {{model}} not supported",
|
||||
|
||||
@@ -254,7 +254,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | Modelos de programación de bajo coste para todos",
|
||||
"go.meta.description":
|
||||
"Go comienza en $5 el primer mes, luego 10 $/mes, con generosos límites de solicitudes de 5 horas para GLM-5, Kimi K2.5 y MiniMax M2.5.",
|
||||
"Go comienza en $5 el primer mes, luego 10 $/mes, con generosos límites de solicitudes de 5 horas para GLM-5, Kimi K2.5, MiniMax M2.5 y MiniMax M2.7.",
|
||||
"go.hero.title": "Modelos de programación de bajo coste para todos",
|
||||
"go.hero.body":
|
||||
"Go lleva la programación agéntica a programadores de todo el mundo. Ofrece límites generosos y acceso fiable a los modelos de código abierto más capaces, para que puedas crear con agentes potentes sin preocuparte por el coste o la disponibilidad.",
|
||||
@@ -303,7 +303,7 @@ export const dict = {
|
||||
"go.problem.item1": "Precios de suscripción de bajo coste",
|
||||
"go.problem.item2": "Límites generosos y acceso fiable",
|
||||
"go.problem.item3": "Creado para tantos programadores como sea posible",
|
||||
"go.problem.item4": "Incluye GLM-5, Kimi K2.5 y MiniMax M2.5",
|
||||
"go.problem.item4": "Incluye GLM-5, Kimi K2.5, MiniMax M2.5 y MiniMax M2.7",
|
||||
"go.how.title": "Cómo funciona Go",
|
||||
"go.how.body": "Go comienza en $5 el primer mes, luego 10 $/mes. Puedes usarlo con OpenCode o cualquier agente.",
|
||||
"go.how.step1.title": "Crear una cuenta",
|
||||
@@ -325,10 +325,10 @@ export const dict = {
|
||||
"go.faq.a1":
|
||||
"Go es una suscripción de bajo coste que te da acceso fiable a modelos de código abierto capaces para programación agéntica.",
|
||||
"go.faq.q2": "¿Qué modelos incluye Go?",
|
||||
"go.faq.a2": "Go incluye GLM-5, Kimi K2.5 y MiniMax M2.5, con límites generosos y acceso fiable.",
|
||||
"go.faq.a2": "Go incluye GLM-5, Kimi K2.5, MiniMax M2.5 y MiniMax M2.7, con límites generosos y acceso fiable.",
|
||||
"go.faq.q3": "¿Es Go lo mismo que Zen?",
|
||||
"go.faq.a3":
|
||||
"No. Zen es pago por uso, mientras que Go comienza en $5 el primer mes, luego 10 $/mes, con límites generosos y acceso fiable a los modelos de código abierto GLM-5, Kimi K2.5 y MiniMax M2.5.",
|
||||
"No. Zen es pago por uso, mientras que Go comienza en $5 el primer mes, luego 10 $/mes, con límites generosos y acceso fiable a los modelos de código abierto GLM-5, Kimi K2.5, MiniMax M2.5 y MiniMax M2.7.",
|
||||
"go.faq.q4": "¿Cuánto cuesta Go?",
|
||||
"go.faq.a4.p1.beforePricing": "Go cuesta",
|
||||
"go.faq.a4.p1.pricingLink": "$5 el primer mes",
|
||||
@@ -353,7 +353,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "¿Cuál es la diferencia entre los modelos gratuitos y Go?",
|
||||
"go.faq.a9":
|
||||
"Los modelos gratuitos incluyen Big Pickle más modelos promocionales disponibles en el momento, con una cuota de 200 solicitudes/día. Go incluye GLM-5, Kimi K2.5 y MiniMax M2.5 con cuotas de solicitud más altas aplicadas a través de ventanas móviles (5 horas, semanal y mensual), aproximadamente equivalente a 12 $ por 5 horas, 30 $ por semana y 60 $ por mes (los recuentos reales de solicitudes varían según el modelo y el uso).",
|
||||
"Los modelos gratuitos incluyen Big Pickle más modelos promocionales disponibles en el momento, con una cuota de 200 solicitudes/día. Go incluye GLM-5, Kimi K2.5, MiniMax M2.5 y MiniMax M2.7 con cuotas de solicitud más altas aplicadas a través de ventanas móviles (5 horas, semanal y mensual), aproximadamente equivalente a 12 $ por 5 horas, 30 $ por semana y 60 $ por mes (los recuentos reales de solicitudes varían según el modelo y el uso).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "Límite de tasa excedido. Por favor, inténtalo de nuevo más tarde.",
|
||||
"zen.api.error.modelNotSupported": "Modelo {{model}} no soportado",
|
||||
|
||||
@@ -255,7 +255,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | Modèles de code à faible coût pour tous",
|
||||
"go.meta.description":
|
||||
"Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites de requêtes généreuses sur 5 heures pour GLM-5, Kimi K2.5 et MiniMax M2.5.",
|
||||
"Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites de requêtes généreuses sur 5 heures pour GLM-5, Kimi K2.5, MiniMax M2.5 et MiniMax M2.7.",
|
||||
"go.hero.title": "Modèles de code à faible coût pour tous",
|
||||
"go.hero.body":
|
||||
"Go apporte le codage agentique aux programmeurs du monde entier. Offrant des limites généreuses et un accès fiable aux modèles open source les plus capables, pour que vous puissiez construire avec des agents puissants sans vous soucier du coût ou de la disponibilité.",
|
||||
@@ -303,7 +303,7 @@ export const dict = {
|
||||
"go.problem.item1": "Prix d'abonnement bas",
|
||||
"go.problem.item2": "Limites généreuses et accès fiable",
|
||||
"go.problem.item3": "Conçu pour autant de programmeurs que possible",
|
||||
"go.problem.item4": "Inclut GLM-5, Kimi K2.5 et MiniMax M2.5",
|
||||
"go.problem.item4": "Inclut GLM-5, Kimi K2.5, MiniMax M2.5 et MiniMax M2.7",
|
||||
"go.how.title": "Comment fonctionne Go",
|
||||
"go.how.body":
|
||||
"Go commence à $5 pour le premier mois, puis 10 $/mois. Vous pouvez l'utiliser avec OpenCode ou n'importe quel agent.",
|
||||
@@ -326,10 +326,11 @@ export const dict = {
|
||||
"go.faq.a1":
|
||||
"Go est un abonnement à faible coût qui vous donne un accès fiable à des modèles open source performants pour le codage agentique.",
|
||||
"go.faq.q2": "Quels modèles Go inclut-il ?",
|
||||
"go.faq.a2": "Go inclut GLM-5, Kimi K2.5 et MiniMax M2.5, avec des limites généreuses et un accès fiable.",
|
||||
"go.faq.a2":
|
||||
"Go inclut GLM-5, Kimi K2.5, MiniMax M2.5 et MiniMax M2.7, avec des limites généreuses et un accès fiable.",
|
||||
"go.faq.q3": "Est-ce que Go est la même chose que Zen ?",
|
||||
"go.faq.a3":
|
||||
"Non. Zen est un paiement à l'utilisation, tandis que Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites généreuses et un accès fiable aux modèles open source GLM-5, Kimi K2.5 et MiniMax M2.5.",
|
||||
"Non. Zen est un paiement à l'utilisation, tandis que Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites généreuses et un accès fiable aux modèles open source GLM-5, Kimi K2.5, MiniMax M2.5 et MiniMax M2.7.",
|
||||
"go.faq.q4": "Combien coûte Go ?",
|
||||
"go.faq.a4.p1.beforePricing": "Go coûte",
|
||||
"go.faq.a4.p1.pricingLink": "$5 le premier mois",
|
||||
@@ -353,7 +354,7 @@ export const dict = {
|
||||
"Oui, vous pouvez utiliser Go avec n'importe quel agent. Suivez les instructions de configuration dans votre agent de code préféré.",
|
||||
"go.faq.q9": "Quelle est la différence entre les modèles gratuits et Go ?",
|
||||
"go.faq.a9":
|
||||
"Les modèles gratuits incluent Big Pickle ainsi que des modèles promotionnels disponibles à ce moment-là, avec un quota de 200 requêtes/jour. Go inclut GLM-5, Kimi K2.5 et MiniMax M2.5 avec des quotas de requêtes plus élevés appliqués sur des fenêtres glissantes (5 heures, hebdomadaire et mensuelle), à peu près équivalent à 12 $ par 5 heures, 30 $ par semaine et 60 $ par mois (le nombre réel de requêtes varie selon le modèle et l'utilisation).",
|
||||
"Les modèles gratuits incluent Big Pickle ainsi que des modèles promotionnels disponibles à ce moment-là, avec un quota de 200 requêtes/jour. Go inclut GLM-5, Kimi K2.5, MiniMax M2.5 et MiniMax M2.7 avec des quotas de requêtes plus élevés appliqués sur des fenêtres glissantes (5 heures, hebdomadaire et mensuelle), à peu près équivalent à 12 $ par 5 heures, 30 $ par semaine et 60 $ par mois (le nombre réel de requêtes varie selon le modèle et l'utilisation).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "Limite de débit dépassée. Veuillez réessayer plus tard.",
|
||||
"zen.api.error.modelNotSupported": "Modèle {{model}} non pris en charge",
|
||||
|
||||
@@ -251,7 +251,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | Modelli di coding a basso costo per tutti",
|
||||
"go.meta.description":
|
||||
"Go inizia a $5 per il primo mese, poi $10/mese, con generosi limiti di richiesta di 5 ore per GLM-5, Kimi K2.5 e MiniMax M2.5.",
|
||||
"Go inizia a $5 per il primo mese, poi $10/mese, con generosi limiti di richiesta di 5 ore per GLM-5, Kimi K2.5, MiniMax M2.5 e MiniMax M2.7.",
|
||||
"go.hero.title": "Modelli di coding a basso costo per tutti",
|
||||
"go.hero.body":
|
||||
"Go porta il coding agentico ai programmatori di tutto il mondo. Offrendo limiti generosi e un accesso affidabile ai modelli open source più capaci, in modo da poter costruire con agenti potenti senza preoccuparsi dei costi o della disponibilità.",
|
||||
@@ -299,7 +299,7 @@ export const dict = {
|
||||
"go.problem.item1": "Prezzo di abbonamento a basso costo",
|
||||
"go.problem.item2": "Limiti generosi e accesso affidabile",
|
||||
"go.problem.item3": "Costruito per il maggior numero possibile di programmatori",
|
||||
"go.problem.item4": "Include GLM-5, Kimi K2.5 e MiniMax M2.5",
|
||||
"go.problem.item4": "Include GLM-5, Kimi K2.5, MiniMax M2.5 e MiniMax M2.7",
|
||||
"go.how.title": "Come funziona Go",
|
||||
"go.how.body": "Go inizia a $5 per il primo mese, poi $10/mese. Puoi usarlo con OpenCode o qualsiasi agente.",
|
||||
"go.how.step1.title": "Crea un account",
|
||||
@@ -321,10 +321,10 @@ export const dict = {
|
||||
"go.faq.a1":
|
||||
"Go è un abbonamento a basso costo che ti dà un accesso affidabile a modelli open source capaci per il coding agentico.",
|
||||
"go.faq.q2": "Quali modelli include Go?",
|
||||
"go.faq.a2": "Go include GLM-5, Kimi K2.5 e MiniMax M2.5, con limiti generosi e accesso affidabile.",
|
||||
"go.faq.a2": "Go include GLM-5, Kimi K2.5, MiniMax M2.5 e MiniMax M2.7, con limiti generosi e accesso affidabile.",
|
||||
"go.faq.q3": "Go è lo stesso di Zen?",
|
||||
"go.faq.a3":
|
||||
"No. Zen è a consumo, mentre Go inizia a $5 per il primo mese, poi $10/mese, con limiti generosi e accesso affidabile ai modelli open source GLM-5, Kimi K2.5 e MiniMax M2.5.",
|
||||
"No. Zen è a consumo, mentre Go inizia a $5 per il primo mese, poi $10/mese, con limiti generosi e accesso affidabile ai modelli open source GLM-5, Kimi K2.5, MiniMax M2.5 e MiniMax M2.7.",
|
||||
"go.faq.q4": "Quanto costa Go?",
|
||||
"go.faq.a4.p1.beforePricing": "Go costa",
|
||||
"go.faq.a4.p1.pricingLink": "$5 il primo mese",
|
||||
@@ -349,7 +349,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "Qual è la differenza tra i modelli gratuiti e Go?",
|
||||
"go.faq.a9":
|
||||
"I modelli gratuiti includono Big Pickle più modelli promozionali disponibili al momento, con una quota di 200 richieste/giorno. Go include GLM-5, Kimi K2.5 e MiniMax M2.5 con quote di richiesta più elevate applicate su finestre mobili (5 ore, settimanale e mensile), approssimativamente equivalenti a $12 ogni 5 ore, $30 a settimana e $60 al mese (il conteggio effettivo delle richieste varia in base al modello e all'utilizzo).",
|
||||
"I modelli gratuiti includono Big Pickle più modelli promozionali disponibili al momento, con una quota di 200 richieste/giorno. Go include GLM-5, Kimi K2.5, MiniMax M2.5 e MiniMax M2.7 con quote di richiesta più elevate applicate su finestre mobili (5 ore, settimanale e mensile), approssimativamente equivalenti a $12 ogni 5 ore, $30 a settimana e $60 al mese (il conteggio effettivo delle richieste varia in base al modello e all'utilizzo).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "Limite di richieste superato. Riprova più tardi.",
|
||||
"zen.api.error.modelNotSupported": "Modello {{model}} non supportato",
|
||||
|
||||
@@ -250,7 +250,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | すべての人のための低価格なコーディングモデル",
|
||||
"go.meta.description":
|
||||
"Goは最初の月$5、その後$10/月で、GLM-5、Kimi K2.5、MiniMax M2.5に対して5時間のゆとりあるリクエスト上限があります。",
|
||||
"Goは最初の月$5、その後$10/月で、GLM-5、Kimi K2.5、MiniMax M2.5、MiniMax M2.7に対して5時間のゆとりあるリクエスト上限があります。",
|
||||
"go.hero.title": "すべての人のための低価格なコーディングモデル",
|
||||
"go.hero.body":
|
||||
"Goは、世界中のプログラマーにエージェント型コーディングをもたらします。最も高性能なオープンソースモデルへの十分な制限と安定したアクセスを提供し、コストや可用性を気にすることなく強力なエージェントで構築できます。",
|
||||
@@ -299,7 +299,7 @@ export const dict = {
|
||||
"go.problem.item1": "低価格なサブスクリプション料金",
|
||||
"go.problem.item2": "十分な制限と安定したアクセス",
|
||||
"go.problem.item3": "できるだけ多くのプログラマーのために構築",
|
||||
"go.problem.item4": "GLM-5、Kimi K2.5、MiniMax M2.5を含む",
|
||||
"go.problem.item4": "GLM-5、Kimi K2.5、MiniMax M2.5、MiniMax M2.7を含む",
|
||||
"go.how.title": "Goの仕組み",
|
||||
"go.how.body": "Goは最初の月$5、その後$10/月で始まります。OpenCodeまたは任意のエージェントで使えます。",
|
||||
"go.how.step1.title": "アカウントを作成",
|
||||
@@ -321,10 +321,11 @@ export const dict = {
|
||||
"go.faq.a1":
|
||||
"Goは、エージェント型コーディングのための有能なオープンソースモデルへの安定したアクセスを提供する低価格なサブスクリプションです。",
|
||||
"go.faq.q2": "Goにはどのモデルが含まれますか?",
|
||||
"go.faq.a2": "Goには、GLM-5、Kimi K2.5、MiniMax M2.5が含まれており、十分な制限と安定したアクセスが提供されます。",
|
||||
"go.faq.a2":
|
||||
"Goには、GLM-5、Kimi K2.5、MiniMax M2.5、MiniMax M2.7が含まれており、十分な制限と安定したアクセスが提供されます。",
|
||||
"go.faq.q3": "GoはZenと同じですか?",
|
||||
"go.faq.a3":
|
||||
"いいえ。Zenは従量課金制ですが、Goは最初の月$5、その後$10/月で始まり、GLM-5、Kimi K2.5、MiniMax M2.5のオープンソースモデルに対して、ゆとりある上限と信頼できるアクセスを提供します。",
|
||||
"いいえ。Zenは従量課金制ですが、Goは最初の月$5、その後$10/月で始まり、GLM-5、Kimi K2.5、MiniMax M2.5、MiniMax M2.7のオープンソースモデルに対して、ゆとりある上限と信頼できるアクセスを提供します。",
|
||||
"go.faq.q4": "Goの料金は?",
|
||||
"go.faq.a4.p1.beforePricing": "Goは",
|
||||
"go.faq.a4.p1.pricingLink": "最初の月$5",
|
||||
@@ -349,7 +350,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "無料モデルとGoの違いは何ですか?",
|
||||
"go.faq.a9":
|
||||
"無料モデルにはBig Pickleと、その時点で利用可能なプロモーションモデルが含まれ、1日200リクエストの制限があります。GoにはGLM-5、Kimi K2.5、MiniMax M2.5が含まれ、ローリングウィンドウ(5時間、週間、月間)全体でより高いリクエスト制限が適用されます。これは概算で5時間あたり$12、週間$30、月間$60相当です(実際のリクエスト数はモデルと使用状況により異なります)。",
|
||||
"無料モデルにはBig Pickleと、その時点で利用可能なプロモーションモデルが含まれ、1日200リクエストの制限があります。GoにはGLM-5、Kimi K2.5、MiniMax M2.5、MiniMax M2.7が含まれ、ローリングウィンドウ(5時間、週間、月間)全体でより高いリクエスト制限が適用されます。これは概算で5時間あたり$12、週間$30、月間$60相当です(実際のリクエスト数はモデルと使用状況により異なります)。",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "レート制限を超えました。後でもう一度お試しください。",
|
||||
"zen.api.error.modelNotSupported": "モデル {{model}} はサポートされていません",
|
||||
|
||||
@@ -247,7 +247,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | 모두를 위한 저비용 코딩 모델",
|
||||
"go.meta.description":
|
||||
"Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5, Kimi K2.5, MiniMax M2.5에 대해 넉넉한 5시간 요청 한도를 제공합니다.",
|
||||
"Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5, Kimi K2.5, MiniMax M2.5, MiniMax M2.7에 대해 넉넉한 5시간 요청 한도를 제공합니다.",
|
||||
"go.hero.title": "모두를 위한 저비용 코딩 모델",
|
||||
"go.hero.body":
|
||||
"Go는 전 세계 프로그래머들에게 에이전트 코딩을 제공합니다. 가장 유능한 오픈 소스 모델에 대한 넉넉한 한도와 안정적인 액세스를 제공하므로, 비용이나 가용성 걱정 없이 강력한 에이전트로 빌드할 수 있습니다.",
|
||||
@@ -296,7 +296,7 @@ export const dict = {
|
||||
"go.problem.item1": "저렴한 구독 가격",
|
||||
"go.problem.item2": "넉넉한 한도와 안정적인 액세스",
|
||||
"go.problem.item3": "가능한 한 많은 프로그래머를 위해 제작됨",
|
||||
"go.problem.item4": "GLM-5, Kimi K2.5, MiniMax M2.5 포함",
|
||||
"go.problem.item4": "GLM-5, Kimi K2.5, MiniMax M2.5, MiniMax M2.7 포함",
|
||||
"go.how.title": "Go 작동 방식",
|
||||
"go.how.body": "Go는 첫 달 $5, 이후 $10/월로 시작합니다. OpenCode 또는 어떤 에이전트와도 함께 사용할 수 있습니다.",
|
||||
"go.how.step1.title": "계정 생성",
|
||||
@@ -317,10 +317,11 @@ export const dict = {
|
||||
"go.faq.q1": "OpenCode Go란 무엇인가요?",
|
||||
"go.faq.a1": "Go는 에이전트 코딩을 위한 유능한 오픈 소스 모델에 대해 안정적인 액세스를 제공하는 저비용 구독입니다.",
|
||||
"go.faq.q2": "Go에는 어떤 모델이 포함되나요?",
|
||||
"go.faq.a2": "Go에는 넉넉한 한도와 안정적인 액세스를 제공하는 GLM-5, Kimi K2.5, MiniMax M2.5가 포함됩니다.",
|
||||
"go.faq.a2":
|
||||
"Go에는 넉넉한 한도와 안정적인 액세스를 제공하는 GLM-5, Kimi K2.5, MiniMax M2.5, MiniMax M2.7가 포함됩니다.",
|
||||
"go.faq.q3": "Go는 Zen과 같은가요?",
|
||||
"go.faq.a3":
|
||||
"아니요. Zen은 종량제인 반면, Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5, Kimi K2.5, MiniMax M2.5 오픈 소스 모델에 대한 넉넉한 한도와 안정적인 액세스를 제공합니다.",
|
||||
"아니요. Zen은 종량제인 반면, Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5, Kimi K2.5, MiniMax M2.5, MiniMax M2.7 오픈 소스 모델에 대한 넉넉한 한도와 안정적인 액세스를 제공합니다.",
|
||||
"go.faq.q4": "Go 비용은 얼마인가요?",
|
||||
"go.faq.a4.p1.beforePricing": "Go 비용은",
|
||||
"go.faq.a4.p1.pricingLink": "첫 달 $5",
|
||||
@@ -344,7 +345,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "무료 모델과 Go의 차이점은 무엇인가요?",
|
||||
"go.faq.a9":
|
||||
"무료 모델에는 Big Pickle과 당시 사용 가능한 프로모션 모델이 포함되며, 하루 200회 요청 할당량이 적용됩니다. Go는 GLM-5, Kimi K2.5, MiniMax M2.5를 포함하며, 롤링 윈도우(5시간, 주간, 월간)에 걸쳐 더 높은 요청 할당량을 적용합니다. 이는 대략 5시간당 $12, 주당 $30, 월 $60에 해당합니다(실제 요청 수는 모델 및 사용량에 따라 다름).",
|
||||
"무료 모델에는 Big Pickle과 당시 사용 가능한 프로모션 모델이 포함되며, 하루 200회 요청 할당량이 적용됩니다. Go는 GLM-5, Kimi K2.5, MiniMax M2.5, MiniMax M2.7를 포함하며, 롤링 윈도우(5시간, 주간, 월간)에 걸쳐 더 높은 요청 할당량을 적용합니다. 이는 대략 5시간당 $12, 주당 $30, 월 $60에 해당합니다(실제 요청 수는 모델 및 사용량에 따라 다름).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "속도 제한을 초과했습니다. 나중에 다시 시도해 주세요.",
|
||||
"zen.api.error.modelNotSupported": "{{model}} 모델은 지원되지 않습니다",
|
||||
|
||||
@@ -251,7 +251,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | Rimelige kodemodeller for alle",
|
||||
"go.meta.description":
|
||||
"Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse 5-timers forespørselsgrenser for GLM-5, Kimi K2.5 og MiniMax M2.5.",
|
||||
"Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse 5-timers forespørselsgrenser for GLM-5, Kimi K2.5, MiniMax M2.5 og MiniMax M2.7.",
|
||||
"go.hero.title": "Rimelige kodemodeller for alle",
|
||||
"go.hero.body":
|
||||
"Go bringer agent-koding til programmerere over hele verden. Med rause grenser og pålitelig tilgang til de mest kapable åpen kildekode-modellene, kan du bygge med kraftige agenter uten å bekymre deg for kostnader eller tilgjengelighet.",
|
||||
@@ -299,7 +299,7 @@ export const dict = {
|
||||
"go.problem.item1": "Rimelig abonnementspris",
|
||||
"go.problem.item2": "Rause grenser og pålitelig tilgang",
|
||||
"go.problem.item3": "Bygget for så mange programmerere som mulig",
|
||||
"go.problem.item4": "Inkluderer GLM-5, Kimi K2.5 og MiniMax M2.5",
|
||||
"go.problem.item4": "Inkluderer GLM-5, Kimi K2.5, MiniMax M2.5 og MiniMax M2.7",
|
||||
"go.how.title": "Hvordan Go fungerer",
|
||||
"go.how.body":
|
||||
"Go starter på $5 for den første måneden, deretter $10/måned. Du kan bruke det med OpenCode eller hvilken som helst agent.",
|
||||
@@ -322,10 +322,10 @@ export const dict = {
|
||||
"go.faq.a1":
|
||||
"Go er et rimelig abonnement som gir deg pålitelig tilgang til kapable åpen kildekode-modeller for agent-koding.",
|
||||
"go.faq.q2": "Hvilke modeller inkluderer Go?",
|
||||
"go.faq.a2": "Go inkluderer GLM-5, Kimi K2.5 og MiniMax M2.5, med rause grenser og pålitelig tilgang.",
|
||||
"go.faq.a2": "Go inkluderer GLM-5, Kimi K2.5, MiniMax M2.5 og MiniMax M2.7, med rause grenser og pålitelig tilgang.",
|
||||
"go.faq.q3": "Er Go det samme som Zen?",
|
||||
"go.faq.a3":
|
||||
"Nei. Zen er betaling etter bruk, mens Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse grenser og pålitelig tilgang til åpen kildekode-modellene GLM-5, Kimi K2.5 og MiniMax M2.5.",
|
||||
"Nei. Zen er betaling etter bruk, mens Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse grenser og pålitelig tilgang til åpen kildekode-modellene GLM-5, Kimi K2.5, MiniMax M2.5 og MiniMax M2.7.",
|
||||
"go.faq.q4": "Hva koster Go?",
|
||||
"go.faq.a4.p1.beforePricing": "Go koster",
|
||||
"go.faq.a4.p1.pricingLink": "$5 første måned",
|
||||
@@ -350,7 +350,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "Hva er forskjellen mellom gratis modeller og Go?",
|
||||
"go.faq.a9":
|
||||
"Gratis modeller inkluderer Big Pickle pluss kampanjemodeller tilgjengelig på det tidspunktet, med en kvote på 200 forespørsler/dag. Go inkluderer GLM-5, Kimi K2.5 og MiniMax M2.5 med høyere kvoter håndhevet over rullerende vinduer (5 timer, ukentlig og månedlig), omtrent tilsvarende $12 per 5 timer, $30 per uke og $60 per måned (faktiske forespørselsantall varierer etter modell og bruk).",
|
||||
"Gratis modeller inkluderer Big Pickle pluss kampanjemodeller tilgjengelig på det tidspunktet, med en kvote på 200 forespørsler/dag. Go inkluderer GLM-5, Kimi K2.5, MiniMax M2.5 og MiniMax M2.7 med høyere kvoter håndhevet over rullerende vinduer (5 timer, ukentlig og månedlig), omtrent tilsvarende $12 per 5 timer, $30 per uke og $60 per måned (faktiske forespørselsantall varierer etter modell og bruk).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "Rate limit overskredet. Vennligst prøv igjen senere.",
|
||||
"zen.api.error.modelNotSupported": "Modell {{model}} støttes ikke",
|
||||
|
||||
@@ -252,7 +252,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | Niskokosztowe modele do kodowania dla każdego",
|
||||
"go.meta.description":
|
||||
"Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi 5-godzinnymi limitami zapytań dla GLM-5, Kimi K2.5 i MiniMax M2.5.",
|
||||
"Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi 5-godzinnymi limitami zapytań dla GLM-5, Kimi K2.5, MiniMax M2.5 i MiniMax M2.7.",
|
||||
"go.hero.title": "Niskokosztowe modele do kodowania dla każdego",
|
||||
"go.hero.body":
|
||||
"Go udostępnia programowanie z agentami programistom na całym świecie. Oferuje hojne limity i niezawodny dostęp do najzdolniejszych modeli open source, dzięki czemu możesz budować za pomocą potężnych agentów, nie martwiąc się o koszty czy dostępność.",
|
||||
@@ -300,7 +300,7 @@ export const dict = {
|
||||
"go.problem.item1": "Niskokosztowa cena subskrypcji",
|
||||
"go.problem.item2": "Hojne limity i niezawodny dostęp",
|
||||
"go.problem.item3": "Stworzony dla jak największej liczby programistów",
|
||||
"go.problem.item4": "Zawiera GLM-5, Kimi K2.5 i MiniMax M2.5",
|
||||
"go.problem.item4": "Zawiera GLM-5, Kimi K2.5, MiniMax M2.5 i MiniMax M2.7",
|
||||
"go.how.title": "Jak działa Go",
|
||||
"go.how.body":
|
||||
"Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc. Możesz go używać z OpenCode lub dowolnym agentem.",
|
||||
@@ -323,10 +323,10 @@ export const dict = {
|
||||
"go.faq.a1":
|
||||
"Go to niskokosztowa subskrypcja, która daje niezawodny dostęp do zdolnych modeli open source dla agentów kodujących.",
|
||||
"go.faq.q2": "Jakie modele zawiera Go?",
|
||||
"go.faq.a2": "Go zawiera GLM-5, Kimi K2.5 i MiniMax M2.5, z hojnymi limitami i niezawodnym dostępem.",
|
||||
"go.faq.a2": "Go zawiera GLM-5, Kimi K2.5, MiniMax M2.5 i MiniMax M2.7, z hojnymi limitami i niezawodnym dostępem.",
|
||||
"go.faq.q3": "Czy Go to to samo co Zen?",
|
||||
"go.faq.a3":
|
||||
"Nie. Zen to model płatności za użycie, podczas gdy Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi limitami i niezawodnym dostępem do modeli open source GLM-5, Kimi K2.5 i MiniMax M2.5.",
|
||||
"Nie. Zen to model płatności za użycie, podczas gdy Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi limitami i niezawodnym dostępem do modeli open source GLM-5, Kimi K2.5, MiniMax M2.5 i MiniMax M2.7.",
|
||||
"go.faq.q4": "Ile kosztuje Go?",
|
||||
"go.faq.a4.p1.beforePricing": "Go kosztuje",
|
||||
"go.faq.a4.p1.pricingLink": "$5 za pierwszy miesiąc",
|
||||
@@ -351,7 +351,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "Jaka jest różnica między darmowymi modelami a Go?",
|
||||
"go.faq.a9":
|
||||
"Darmowe modele obejmują Big Pickle oraz modele promocyjne dostępne w danym momencie, z limitem 200 zapytań/dzień. Go zawiera GLM-5, Kimi K2.5 i MiniMax M2.5 z wyższymi limitami zapytań egzekwowanymi w oknach kroczących (5-godzinnych, tygodniowych i miesięcznych), w przybliżeniu równoważnymi $12 na 5 godzin, $30 tygodniowo i $60 miesięcznie (rzeczywista liczba zapytań zależy od modelu i użycia).",
|
||||
"Darmowe modele obejmują Big Pickle oraz modele promocyjne dostępne w danym momencie, z limitem 200 zapytań/dzień. Go zawiera GLM-5, Kimi K2.5, MiniMax M2.5 i MiniMax M2.7 z wyższymi limitami zapytań egzekwowanymi w oknach kroczących (5-godzinnych, tygodniowych i miesięcznych), w przybliżeniu równoważnymi $12 na 5 godzin, $30 tygodniowo i $60 miesięcznie (rzeczywista liczba zapytań zależy od modelu i użycia).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "Przekroczono limit zapytań. Spróbuj ponownie później.",
|
||||
"zen.api.error.modelNotSupported": "Model {{model}} nie jest obsługiwany",
|
||||
|
||||
@@ -255,7 +255,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | Недорогие модели для кодинга для всех",
|
||||
"go.meta.description":
|
||||
"Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами запросов за 5 часов для GLM-5, Kimi K2.5 и MiniMax M2.5.",
|
||||
"Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами запросов за 5 часов для GLM-5, Kimi K2.5, MiniMax M2.5 и MiniMax M2.7.",
|
||||
"go.hero.title": "Недорогие модели для кодинга для всех",
|
||||
"go.hero.body":
|
||||
"Go открывает доступ к агентам-программистам разработчикам по всему миру. Предлагая щедрые лимиты и надежный доступ к наиболее способным моделям с открытым исходным кодом, вы можете создавать проекты с мощными агентами, не беспокоясь о затратах или доступности.",
|
||||
@@ -304,7 +304,7 @@ export const dict = {
|
||||
"go.problem.item1": "Недорогая подписка",
|
||||
"go.problem.item2": "Щедрые лимиты и надежный доступ",
|
||||
"go.problem.item3": "Создан для максимального числа программистов",
|
||||
"go.problem.item4": "Включает GLM-5, Kimi K2.5 и MiniMax M2.5",
|
||||
"go.problem.item4": "Включает GLM-5, Kimi K2.5, MiniMax M2.5 и MiniMax M2.7",
|
||||
"go.how.title": "Как работает Go",
|
||||
"go.how.body":
|
||||
"Go начинается с $5 за первый месяц, затем $10/месяц. Вы можете использовать его с OpenCode или любым агентом.",
|
||||
@@ -327,10 +327,10 @@ export const dict = {
|
||||
"go.faq.a1":
|
||||
"Go — это недорогая подписка, дающая надежный доступ к мощным моделям с открытым исходным кодом для агентов-программистов.",
|
||||
"go.faq.q2": "Какие модели включает Go?",
|
||||
"go.faq.a2": "Go включает GLM-5, Kimi K2.5 и MiniMax M2.5, с щедрыми лимитами и надежным доступом.",
|
||||
"go.faq.a2": "Go включает GLM-5, Kimi K2.5, MiniMax M2.5 и MiniMax M2.7, с щедрыми лимитами и надежным доступом.",
|
||||
"go.faq.q3": "Go — это то же самое, что и Zen?",
|
||||
"go.faq.a3":
|
||||
"Нет. Zen - это оплата по мере использования, в то время как Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами и надежным доступом к моделям с открытым исходным кодом GLM-5, Kimi K2.5 и MiniMax M2.5.",
|
||||
"Нет. Zen - это оплата по мере использования, в то время как Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами и надежным доступом к моделям с открытым исходным кодом GLM-5, Kimi K2.5, MiniMax M2.5 и MiniMax M2.7.",
|
||||
"go.faq.q4": "Сколько стоит Go?",
|
||||
"go.faq.a4.p1.beforePricing": "Go стоит",
|
||||
"go.faq.a4.p1.pricingLink": "$5 за первый месяц",
|
||||
@@ -355,7 +355,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "В чем разница между бесплатными моделями и Go?",
|
||||
"go.faq.a9":
|
||||
"Бесплатные модели включают Big Pickle плюс промо-модели, доступные на данный момент, с квотой 200 запросов/день. Go включает GLM-5, Kimi K2.5 и MiniMax M2.5 с более высокими квотами запросов, применяемыми в скользящих окнах (5 часов, неделя и месяц), что примерно эквивалентно $12 за 5 часов, $30 в неделю и $60 в месяц (фактическое количество запросов зависит от модели и использования).",
|
||||
"Бесплатные модели включают Big Pickle плюс промо-модели, доступные на данный момент, с квотой 200 запросов/день. Go включает GLM-5, Kimi K2.5, MiniMax M2.5 и MiniMax M2.7 с более высокими квотами запросов, применяемыми в скользящих окнах (5 часов, неделя и месяц), что примерно эквивалентно $12 за 5 часов, $30 в неделю и $60 в месяц (фактическое количество запросов зависит от модели и использования).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "Превышен лимит запросов. Пожалуйста, попробуйте позже.",
|
||||
"zen.api.error.modelNotSupported": "Модель {{model}} не поддерживается",
|
||||
|
||||
@@ -250,7 +250,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | โมเดลเขียนโค้ดราคาประหยัดสำหรับทุกคน",
|
||||
"go.meta.description":
|
||||
"Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดคำขอ 5 ชั่วโมงที่เอื้อเฟื้อสำหรับ GLM-5, Kimi K2.5 และ MiniMax M2.5",
|
||||
"Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดคำขอ 5 ชั่วโมงที่เอื้อเฟื้อสำหรับ GLM-5, Kimi K2.5, MiniMax M2.5 และ MiniMax M2.7",
|
||||
"go.hero.title": "โมเดลเขียนโค้ดราคาประหยัดสำหรับทุกคน",
|
||||
"go.hero.body":
|
||||
"Go นำการเขียนโค้ดแบบเอเจนต์มาสู่นักเขียนโปรแกรมทั่วโลก เสนอขีดจำกัดที่กว้างขวางและการเข้าถึงโมเดลโอเพนซอร์สที่มีความสามารถสูงสุดได้อย่างน่าเชื่อถือ เพื่อให้คุณสามารถสร้างสรรค์ด้วยเอเจนต์ที่ทรงพลังโดยไม่ต้องกังวลเรื่องค่าใช้จ่ายหรือความพร้อมใช้งาน",
|
||||
@@ -297,7 +297,7 @@ export const dict = {
|
||||
"go.problem.item1": "ราคาการสมัครสมาชิกที่ต่ำ",
|
||||
"go.problem.item2": "ขีดจำกัดที่กว้างขวางและการเข้าถึงที่เชื่อถือได้",
|
||||
"go.problem.item3": "สร้างขึ้นเพื่อโปรแกรมเมอร์จำนวนมากที่สุดเท่าที่จะเป็นไปได้",
|
||||
"go.problem.item4": "รวมถึง GLM-5, Kimi K2.5 และ MiniMax M2.5",
|
||||
"go.problem.item4": "รวมถึง GLM-5, Kimi K2.5, MiniMax M2.5 และ MiniMax M2.7",
|
||||
"go.how.title": "Go ทำงานอย่างไร",
|
||||
"go.how.body": "Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน คุณสามารถใช้กับ OpenCode หรือเอเจนต์ใดก็ได้",
|
||||
"go.how.step1.title": "สร้างบัญชี",
|
||||
@@ -319,10 +319,11 @@ export const dict = {
|
||||
"go.faq.a1":
|
||||
"Go คือการสมัครสมาชิกราคาประหยัดที่ให้คุณเข้าถึงโมเดลโอเพนซอร์สที่มีความสามารถสำหรับการเขียนโค้ดแบบเอเจนต์ได้อย่างน่าเชื่อถือ",
|
||||
"go.faq.q2": "Go รวมโมเดลอะไรบ้าง?",
|
||||
"go.faq.a2": "Go รวมถึง GLM-5, Kimi K2.5 และ MiniMax M2.5 พร้อมขีดจำกัดที่กว้างขวางและการเข้าถึงที่เชื่อถือได้",
|
||||
"go.faq.a2":
|
||||
"Go รวมถึง GLM-5, Kimi K2.5, MiniMax M2.5 และ MiniMax M2.7 พร้อมขีดจำกัดที่กว้างขวางและการเข้าถึงที่เชื่อถือได้",
|
||||
"go.faq.q3": "Go เหมือนกับ Zen หรือไม่?",
|
||||
"go.faq.a3":
|
||||
"ไม่ Zen เป็นแบบจ่ายตามการใช้งาน ในขณะที่ Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดที่เอื้อเฟื้อและการเข้าถึงโมเดลโอเพนซอร์ส GLM-5, Kimi K2.5 และ MiniMax M2.5 อย่างเชื่อถือได้",
|
||||
"ไม่ Zen เป็นแบบจ่ายตามการใช้งาน ในขณะที่ Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดที่เอื้อเฟื้อและการเข้าถึงโมเดลโอเพนซอร์ส GLM-5, Kimi K2.5, MiniMax M2.5 และ MiniMax M2.7 อย่างเชื่อถือได้",
|
||||
"go.faq.q4": "Go ราคาเท่าไหร่?",
|
||||
"go.faq.a4.p1.beforePricing": "Go ราคา",
|
||||
"go.faq.a4.p1.pricingLink": "$5 เดือนแรก",
|
||||
@@ -346,7 +347,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "ความแตกต่างระหว่างโมเดลฟรีและ Go คืออะไร?",
|
||||
"go.faq.a9":
|
||||
"โมเดลฟรีรวมถึง Big Pickle บวกกับโมเดลโปรโมชั่นที่มีให้ในขณะนั้น ด้วยโควต้า 200 คำขอ/วัน Go รวมถึง GLM-5, Kimi K2.5 และ MiniMax M2.5 ที่มีโควต้าคำขอสูงกว่า ซึ่งบังคับใช้ผ่านช่วงเวลาหมุนเวียน (5 ชั่วโมง, รายสัปดาห์ และรายเดือน) เทียบเท่าประมาณ $12 ต่อ 5 ชั่วโมง, $30 ต่อสัปดาห์ และ $60 ต่อเดือน (จำนวนคำขอจริงจะแตกต่างกันไปตามโมเดลและการใช้งาน)",
|
||||
"โมเดลฟรีรวมถึง Big Pickle บวกกับโมเดลโปรโมชั่นที่มีให้ในขณะนั้น ด้วยโควต้า 200 คำขอ/วัน Go รวมถึง GLM-5, Kimi K2.5, MiniMax M2.5 และ MiniMax M2.7 ที่มีโควต้าคำขอสูงกว่า ซึ่งบังคับใช้ผ่านช่วงเวลาหมุนเวียน (5 ชั่วโมง, รายสัปดาห์ และรายเดือน) เทียบเท่าประมาณ $12 ต่อ 5 ชั่วโมง, $30 ต่อสัปดาห์ และ $60 ต่อเดือน (จำนวนคำขอจริงจะแตกต่างกันไปตามโมเดลและการใช้งาน)",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "เกินขีดจำกัดอัตราการใช้งาน กรุณาลองใหม่ในภายหลัง",
|
||||
"zen.api.error.modelNotSupported": "ไม่รองรับโมเดล {{model}}",
|
||||
|
||||
@@ -253,7 +253,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | Herkes için düşük maliyetli kodlama modelleri",
|
||||
"go.meta.description":
|
||||
"Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5, Kimi K2.5 ve MiniMax M2.5 için cömert 5 saatlik istek limitleri sunar.",
|
||||
"Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5, Kimi K2.5, MiniMax M2.5 ve MiniMax M2.7 için cömert 5 saatlik istek limitleri sunar.",
|
||||
"go.hero.title": "Herkes için düşük maliyetli kodlama modelleri",
|
||||
"go.hero.body":
|
||||
"Go, dünya çapındaki programcılara ajan tabanlı kodlama getiriyor. En yetenekli açık kaynaklı modellere cömert limitler ve güvenilir erişim sunarak, maliyet veya erişilebilirlik konusunda endişelenmeden güçlü ajanlarla geliştirme yapmanızı sağlar.",
|
||||
@@ -302,7 +302,7 @@ export const dict = {
|
||||
"go.problem.item1": "Düşük maliyetli abonelik fiyatlandırması",
|
||||
"go.problem.item2": "Cömert limitler ve güvenilir erişim",
|
||||
"go.problem.item3": "Mümkün olduğunca çok programcı için geliştirildi",
|
||||
"go.problem.item4": "GLM-5, Kimi K2.5 ve MiniMax M2.5 içerir",
|
||||
"go.problem.item4": "GLM-5, Kimi K2.5, MiniMax M2.5 ve MiniMax M2.7 içerir",
|
||||
"go.how.title": "Go nasıl çalışır?",
|
||||
"go.how.body":
|
||||
"Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar. OpenCode veya herhangi bir ajanla kullanabilirsiniz.",
|
||||
@@ -325,10 +325,11 @@ export const dict = {
|
||||
"go.faq.a1":
|
||||
"Go, ajan tabanlı kodlama için yetenekli açık kaynaklı modellere güvenilir erişim sağlayan düşük maliyetli bir aboneliktir.",
|
||||
"go.faq.q2": "Go hangi modelleri içerir?",
|
||||
"go.faq.a2": "Go, cömert limitler ve güvenilir erişim ile GLM-5, Kimi K2.5 ve MiniMax M2.5 modellerini içerir.",
|
||||
"go.faq.a2":
|
||||
"Go, cömert limitler ve güvenilir erişim ile GLM-5, Kimi K2.5, MiniMax M2.5 ve MiniMax M2.7 modellerini içerir.",
|
||||
"go.faq.q3": "Go, Zen ile aynı mı?",
|
||||
"go.faq.a3":
|
||||
"Hayır. Zen kullandıkça öde modelidir, Go ise ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5, Kimi K2.5 ve MiniMax M2.5 açık kaynak modellerine cömert limitler ve güvenilir erişim sunar.",
|
||||
"Hayır. Zen kullandıkça öde modelidir, Go ise ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5, Kimi K2.5, MiniMax M2.5 ve MiniMax M2.7 açık kaynak modellerine cömert limitler ve güvenilir erişim sunar.",
|
||||
"go.faq.q4": "Go ne kadar?",
|
||||
"go.faq.a4.p1.beforePricing": "Go'nun maliyeti",
|
||||
"go.faq.a4.p1.pricingLink": "İlk ay $5",
|
||||
@@ -353,7 +354,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "Ücretsiz modeller ve Go arasındaki fark nedir?",
|
||||
"go.faq.a9":
|
||||
"Ücretsiz modeller, günlük 200 istek kotası ile Big Pickle ve o sırada mevcut olan promosyonel modelleri içerir. Go ise GLM-5, Kimi K2.5 ve MiniMax M2.5 modellerini; yuvarlanan pencereler (5 saatlik, haftalık ve aylık) üzerinden uygulanan daha yüksek istek kotalarıyla içerir. Bu kotalar kabaca her 5 saatte 12$, haftada 30$ ve ayda 60$ değerine eşdeğerdir (gerçek istek sayıları modele ve kullanıma göre değişir).",
|
||||
"Ücretsiz modeller, günlük 200 istek kotası ile Big Pickle ve o sırada mevcut olan promosyonel modelleri içerir. Go ise GLM-5, Kimi K2.5, MiniMax M2.5 ve MiniMax M2.7 modellerini; yuvarlanan pencereler (5 saatlik, haftalık ve aylık) üzerinden uygulanan daha yüksek istek kotalarıyla içerir. Bu kotalar kabaca her 5 saatte 12$, haftada 30$ ve ayda 60$ değerine eşdeğerdir (gerçek istek sayıları modele ve kullanıma göre değişir).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "İstek limiti aşıldı. Lütfen daha sonra tekrar deneyin.",
|
||||
"zen.api.error.modelNotSupported": "{{model}} modeli desteklenmiyor",
|
||||
|
||||
@@ -240,7 +240,8 @@ export const dict = {
|
||||
"zen.privacy.exceptionsLink": "以下例外情况除外",
|
||||
|
||||
"go.title": "OpenCode Go | 人人可用的低成本编程模型",
|
||||
"go.meta.description": "Go 首月 $5,之后 $10/月,提供对 GLM-5、Kimi K2.5 和 MiniMax M2.5 的 5 小时充裕请求额度。",
|
||||
"go.meta.description":
|
||||
"Go 首月 $5,之后 $10/月,提供对 GLM-5、Kimi K2.5、MiniMax M2.5 和 MiniMax M2.7 的 5 小时充裕请求额度。",
|
||||
"go.hero.title": "人人可用的低成本编程模型",
|
||||
"go.hero.body":
|
||||
"Go 将代理编程带给全世界的程序员。提供充裕的限额和对最强大的开源模型的可靠访问,让您可以利用强大的代理进行构建,而无需担心成本或可用性。",
|
||||
@@ -287,7 +288,7 @@ export const dict = {
|
||||
"go.problem.item1": "低成本订阅定价",
|
||||
"go.problem.item2": "充裕的限额和可靠的访问",
|
||||
"go.problem.item3": "为尽可能多的程序员打造",
|
||||
"go.problem.item4": "包含 GLM-5, Kimi K2.5, 和 MiniMax M2.5",
|
||||
"go.problem.item4": "包含 GLM-5, Kimi K2.5, MiniMax M2.5 和 MiniMax M2.7",
|
||||
"go.how.title": "Go 如何工作",
|
||||
"go.how.body": "Go 起价为首月 $5,之后 $10/月。您可以将其与 OpenCode 或任何代理搭配使用。",
|
||||
"go.how.step1.title": "创建账户",
|
||||
@@ -306,10 +307,10 @@ export const dict = {
|
||||
"go.faq.q1": "什么是 OpenCode Go?",
|
||||
"go.faq.a1": "Go 是一项低成本订阅服务,为您提供对强大的开源模型的可靠访问,用于代理编程。",
|
||||
"go.faq.q2": "Go 包含哪些模型?",
|
||||
"go.faq.a2": "Go 包含 GLM-5, Kimi K2.5, 和 MiniMax M2.5,并提供充裕的限额和可靠的访问。",
|
||||
"go.faq.a2": "Go 包含 GLM-5, Kimi K2.5, MiniMax M2.5 和 MiniMax M2.7,并提供充裕的限额和可靠的访问。",
|
||||
"go.faq.q3": "Go 和 Zen 一样吗?",
|
||||
"go.faq.a3":
|
||||
"不。Zen 是按量付费,而 Go 首月 $5,之后 $10/月,提供充裕的额度,并可可靠地访问 GLM-5、Kimi K2.5 和 MiniMax M2.5 等开源模型。",
|
||||
"不。Zen 是按量付费,而 Go 首月 $5,之后 $10/月,提供充裕的额度,并可可靠地访问 GLM-5、Kimi K2.5、MiniMax M2.5 和 MiniMax M2.7 等开源模型。",
|
||||
"go.faq.q4": "Go 多少钱?",
|
||||
"go.faq.a4.p1.beforePricing": "Go 费用为",
|
||||
"go.faq.a4.p1.pricingLink": "首月 $5",
|
||||
@@ -331,7 +332,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "免费模型和 Go 之间的区别是什么?",
|
||||
"go.faq.a9":
|
||||
"免费模型包含 Big Pickle 加上当时可用的促销模型,每天有 200 次请求的配额。Go 包含 GLM-5, Kimi K2.5, 和 MiniMax M2.5,并在滚动窗口(5 小时、每周和每月)内执行更高的请求配额,大致相当于每 5 小时 $12、每周 $30 和每月 $60(实际请求计数因模型和使用情况而异)。",
|
||||
"免费模型包含 Big Pickle 加上当时可用的促销模型,每天有 200 次请求的配额。Go 包含 GLM-5, Kimi K2.5, MiniMax M2.5 和 MiniMax M2.7,并在滚动窗口(5 小时、每周和每月)内执行更高的请求配额,大致相当于每 5 小时 $12、每周 $30 和每月 $60(实际请求计数因模型和使用情况而异)。",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "超出速率限制。请稍后重试。",
|
||||
"zen.api.error.modelNotSupported": "不支持模型 {{model}}",
|
||||
|
||||
@@ -240,7 +240,8 @@ export const dict = {
|
||||
"zen.privacy.exceptionsLink": "以下例外情況",
|
||||
|
||||
"go.title": "OpenCode Go | 低成本全民編碼模型",
|
||||
"go.meta.description": "Go 首月 $5,之後 $10/月,提供對 GLM-5、Kimi K2.5 和 MiniMax M2.5 的 5 小時充裕請求額度。",
|
||||
"go.meta.description":
|
||||
"Go 首月 $5,之後 $10/月,提供對 GLM-5、Kimi K2.5、MiniMax M2.5 和 MiniMax M2.7 的 5 小時充裕請求額度。",
|
||||
"go.hero.title": "低成本全民編碼模型",
|
||||
"go.hero.body":
|
||||
"Go 將代理編碼帶給全世界的程式設計師。提供寬裕的限額以及對最強大開源模型的穩定存取,讓你可以使用強大的代理進行構建,而無需擔心成本或可用性。",
|
||||
@@ -287,7 +288,7 @@ export const dict = {
|
||||
"go.problem.item1": "低成本訂閱定價",
|
||||
"go.problem.item2": "寬裕的限額與穩定存取",
|
||||
"go.problem.item3": "專為盡可能多的程式設計師打造",
|
||||
"go.problem.item4": "包含 GLM-5、Kimi K2.5 與 MiniMax M2.5",
|
||||
"go.problem.item4": "包含 GLM-5、Kimi K2.5、MiniMax M2.5 與 MiniMax M2.7",
|
||||
"go.how.title": "Go 如何運作",
|
||||
"go.how.body": "Go 起價為首月 $5,之後 $10/月。您可以將其與 OpenCode 或任何代理搭配使用。",
|
||||
"go.how.step1.title": "建立帳號",
|
||||
@@ -306,10 +307,10 @@ export const dict = {
|
||||
"go.faq.q1": "什麼是 OpenCode Go?",
|
||||
"go.faq.a1": "Go 是一個低成本訂閱方案,讓你穩定存取強大的開源模型以進行代理編碼。",
|
||||
"go.faq.q2": "Go 包含哪些模型?",
|
||||
"go.faq.a2": "Go 包含 GLM-5、Kimi K2.5 與 MiniMax M2.5,並提供寬裕的限額與穩定存取。",
|
||||
"go.faq.a2": "Go 包含 GLM-5、Kimi K2.5、MiniMax M2.5 與 MiniMax M2.7,並提供寬裕的限額與穩定存取。",
|
||||
"go.faq.q3": "Go 與 Zen 一樣嗎?",
|
||||
"go.faq.a3":
|
||||
"不。Zen 是按量付費,而 Go 首月 $5,之後 $10/月,提供充裕的額度,並可可靠地存取 GLM-5、Kimi K2.5 和 MiniMax M2.5 等開源模型。",
|
||||
"不。Zen 是按量付費,而 Go 首月 $5,之後 $10/月,提供充裕的額度,並可可靠地存取 GLM-5、Kimi K2.5、MiniMax M2.5 和 MiniMax M2.7 等開源模型。",
|
||||
"go.faq.q4": "Go 費用是多少?",
|
||||
"go.faq.a4.p1.beforePricing": "Go 費用為",
|
||||
"go.faq.a4.p1.pricingLink": "首月 $5",
|
||||
@@ -331,7 +332,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "免費模型與 Go 有什麼區別?",
|
||||
"go.faq.a9":
|
||||
"免費模型包括 Big Pickle 以及當時可用的促銷模型,配額為 200 次請求/天。Go 包括 GLM-5、Kimi K2.5 與 MiniMax M2.5,並在滾動視窗(5 小時、每週和每月)內執行更高的請求配額,大約相當於每 5 小時 $12、每週 $30 和每月 $60(實際請求數因模型和使用情況而異)。",
|
||||
"免費模型包括 Big Pickle 以及當時可用的促銷模型,配額為 200 次請求/天。Go 包括 GLM-5、Kimi K2.5、MiniMax M2.5 與 MiniMax M2.7,並在滾動視窗(5 小時、每週和每月)內執行更高的請求配額,大約相當於每 5 小時 $12、每週 $30 和每月 $60(實際請求數因模型和使用情況而異)。",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "超出頻率限制。請稍後再試。",
|
||||
"zen.api.error.modelNotSupported": "不支援模型 {{model}}",
|
||||
|
||||
@@ -47,7 +47,8 @@ function LimitsGraph(props: { href: string }) {
|
||||
const models = [
|
||||
{ id: "glm", name: "GLM-5", req: 1150, d: "120ms" },
|
||||
{ id: "kimi", name: "Kimi K2.5", req: 1850, d: "240ms" },
|
||||
{ id: "minimax", name: "MiniMax M2.5", req: 20000, d: "360ms" },
|
||||
{ id: "minimax-m2.7", name: "MiniMax M2.7", req: 14000, d: "330ms" },
|
||||
{ id: "minimax-m2.5", name: "MiniMax M2.5", req: 20000, d: "360ms" },
|
||||
]
|
||||
|
||||
const w = 720
|
||||
|
||||
@@ -279,6 +279,7 @@ export function LiteSection() {
|
||||
<li>Kimi K2.5</li>
|
||||
<li>GLM-5</li>
|
||||
<li>MiniMax M2.5</li>
|
||||
<li>MiniMax M2.7</li>
|
||||
</ul>
|
||||
<p data-slot="promo-description">{i18n.t("workspace.lite.promo.footer")}</p>
|
||||
<button
|
||||
|
||||
@@ -9,71 +9,55 @@
|
||||
- **Output**: creates `migration/<timestamp>_<slug>/migration.sql` and `snapshot.json`.
|
||||
- **Tests**: migration tests should read the per-folder layout (no `_journal.json`).
|
||||
|
||||
# opencode Effect guide
|
||||
# opencode Effect rules
|
||||
|
||||
Instructions to follow when writing Effect.
|
||||
Use these rules when writing or migrating Effect code.
|
||||
|
||||
## Schemas
|
||||
See `specs/effect-migration.md` for the compact pattern reference and examples.
|
||||
|
||||
- Use `Schema.Class` for data types with multiple fields.
|
||||
- Use branded schemas (`Schema.brand`) for single-value types.
|
||||
|
||||
## Services
|
||||
|
||||
- Services use `ServiceMap.Service<ServiceName, ServiceName.Service>()("@console/<Name>")`.
|
||||
- In `Layer.effect`, always return service implementations with `ServiceName.of({ ... })`, never a plain object.
|
||||
|
||||
## Errors
|
||||
|
||||
- Use `Schema.TaggedErrorClass` for typed errors.
|
||||
- For defect-like causes, use `Schema.Defect` instead of `unknown`.
|
||||
- In `Effect.gen`, prefer `yield* new MyError(...)` over `yield* Effect.fail(new MyError(...))` for direct early-failure branches.
|
||||
|
||||
## Effects
|
||||
## Core
|
||||
|
||||
- Use `Effect.gen(function* () { ... })` for composition.
|
||||
- Use `Effect.fn("ServiceName.method")` for named/traced effects and `Effect.fnUntraced` for internal helpers.
|
||||
- `Effect.fn` / `Effect.fnUntraced` accept pipeable operators as extra arguments, so avoid unnecessary `flow` or outer `.pipe()` wrappers.
|
||||
- **`Effect.callback`** (not `Effect.async`) for callback-based APIs. The classic `Effect.async` was renamed to `Effect.callback` in effect-smol/v4.
|
||||
|
||||
## Time
|
||||
|
||||
- Use `Effect.fn("Domain.method")` for named/traced effects and `Effect.fnUntraced` for internal helpers.
|
||||
- `Effect.fn` / `Effect.fnUntraced` accept pipeable operators as extra arguments, so avoid unnecessary outer `.pipe()` wrappers.
|
||||
- Use `Effect.callback` for callback-based APIs.
|
||||
- Prefer `DateTime.nowAsDate` over `new Date(yield* Clock.currentTimeMillis)` when you need a `Date`.
|
||||
|
||||
## Errors
|
||||
## Schemas and errors
|
||||
|
||||
- In `Effect.gen/fn`, prefer `yield* new MyError(...)` over `yield* Effect.fail(new MyError(...))` for direct early-failure branches.
|
||||
- Use `Schema.Class` for multi-field data.
|
||||
- Use branded schemas (`Schema.brand`) for single-value types.
|
||||
- Use `Schema.TaggedErrorClass` for typed errors.
|
||||
- Use `Schema.Defect` instead of `unknown` for defect-like causes.
|
||||
- In `Effect.gen` / `Effect.fn`, prefer `yield* new MyError(...)` over `yield* Effect.fail(new MyError(...))` for direct early-failure branches.
|
||||
|
||||
## Instance-scoped Effect services
|
||||
## Runtime vs Instances
|
||||
|
||||
Services that need per-directory lifecycle (created/destroyed per instance) go through the `Instances` LayerMap:
|
||||
- Use the shared runtime for process-wide services with one lifecycle for the whole app.
|
||||
- Use `src/effect/instances.ts` for per-directory or per-project services that need `InstanceContext`, per-instance state, or per-instance cleanup.
|
||||
- If two open directories should not share one copy of the service, it belongs in `Instances`.
|
||||
- Instance-scoped services should read context from `InstanceContext`, not `Instance.*` globals.
|
||||
|
||||
1. Define a `ServiceMap.Service` with a `static readonly layer` (see `FileWatcherService`, `QuestionService`, `PermissionService`, `ProviderAuthService`).
|
||||
2. Add it to `InstanceServices` union and `Layer.mergeAll(...)` in `src/effect/instances.ts`.
|
||||
3. Use `InstanceContext` inside the layer to read `directory` and `project` instead of `Instance.*` globals.
|
||||
4. Call from legacy code via `runPromiseInstance(MyService.use((s) => s.method()))`.
|
||||
## Preferred Effect services
|
||||
|
||||
### Instance.bind — ALS context for native callbacks
|
||||
- In effectified services, prefer yielding existing Effect services over dropping down to ad hoc platform APIs.
|
||||
- Prefer `FileSystem.FileSystem` instead of raw `fs/promises` for effectful file I/O.
|
||||
- Prefer `ChildProcessSpawner.ChildProcessSpawner` with `ChildProcess.make(...)` instead of custom process wrappers.
|
||||
- Prefer `HttpClient.HttpClient` instead of raw `fetch`.
|
||||
- Prefer `Path.Path`, `Config`, `Clock`, and `DateTime` when those concerns are already inside Effect code.
|
||||
- For background loops or scheduled tasks, use `Effect.repeat` or `Effect.schedule` with `Effect.forkScoped` in the layer definition.
|
||||
|
||||
`Instance.bind(fn)` captures the current Instance AsyncLocalStorage context and returns a wrapper that restores it synchronously when called.
|
||||
## Instance.bind — ALS for native callbacks
|
||||
|
||||
**Use it** when passing callbacks to native C/C++ addons (`@parcel/watcher`, `node-pty`, native `fs.watch`, etc.) that need to call `Bus.publish`, `Instance.state()`, or anything that reads `Instance.directory`.
|
||||
`Instance.bind(fn)` captures the current Instance AsyncLocalStorage context and restores it synchronously when called.
|
||||
|
||||
**Don't need it** for `setTimeout`, `Promise.then`, `EventEmitter.on`, or Effect fibers — Node.js ALS propagates through those automatically.
|
||||
Use it for native addon callbacks (`@parcel/watcher`, `node-pty`, native `fs.watch`, etc.) that need to call `Bus.publish`, `Instance.state()`, or anything that reads `Instance.directory`.
|
||||
|
||||
You do not need it for `setTimeout`, `Promise.then`, `EventEmitter.on`, or Effect fibers.
|
||||
|
||||
```typescript
|
||||
// Native addon callback — needs Instance.bind
|
||||
const cb = Instance.bind((err, evts) => {
|
||||
Bus.publish(MyEvent, { ... })
|
||||
})
|
||||
nativeAddon.subscribe(dir, cb)
|
||||
```
|
||||
|
||||
## Flag → Effect.Config migration
|
||||
|
||||
Flags in `src/flag/flag.ts` are being migrated from static `truthy(...)` reads to `Config.boolean(...).pipe(Config.withDefault(false))` as their consumers get effectified.
|
||||
|
||||
- Effectful flags return `Config<boolean>` and are read with `yield*` inside `Effect.gen`.
|
||||
- The default `ConfigProvider` reads from `process.env`, so env vars keep working.
|
||||
- Tests can override via `ConfigProvider.layer(ConfigProvider.fromUnknown({ ... }))`.
|
||||
- Keep all flags in `flag.ts` as the single registry — just change the implementation from `truthy()` to `Config.boolean()` when the consumer moves to Effect.
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"test": "bun test --timeout 30000",
|
||||
"test": "bun test --timeout 30000 registry",
|
||||
"build": "bun run script/build.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'",
|
||||
@@ -25,9 +25,15 @@
|
||||
"exports": {
|
||||
"./*": "./src/*.ts"
|
||||
},
|
||||
"imports": {
|
||||
"#db": {
|
||||
"bun": "./src/storage/db.bun.ts",
|
||||
"node": "./src/storage/db.node.ts",
|
||||
"default": "./src/storage/db.bun.ts"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.28.4",
|
||||
"@effect/language-service": "0.79.0",
|
||||
"@octokit/webhooks-types": "7.6.1",
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@parcel/watcher-darwin-arm64": "2.5.1",
|
||||
@@ -43,13 +49,14 @@
|
||||
"@types/babel__core": "7.20.5",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/npmcli__arborist": "6.3.3",
|
||||
"@types/semver": "^7.5.8",
|
||||
"@types/turndown": "5.0.5",
|
||||
"@types/yargs": "17.0.33",
|
||||
"@types/which": "3.0.4",
|
||||
"@types/yargs": "17.0.33",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"drizzle-kit": "1.0.0-beta.16-ea816b6",
|
||||
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
||||
"effect": "catalog:",
|
||||
"drizzle-kit": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vscode-languageserver-types": "3.17.5",
|
||||
"why-is-node-running": "3.2.2",
|
||||
@@ -82,9 +89,12 @@
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@gitlab/gitlab-ai-provider": "3.6.0",
|
||||
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
||||
"@hono/node-server": "1.19.11",
|
||||
"@hono/node-ws": "1.3.0",
|
||||
"@hono/standard-validator": "0.1.5",
|
||||
"@hono/zod-validator": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.25.2",
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/graphql": "9.0.2",
|
||||
"@octokit/rest": "catalog:",
|
||||
"@openauthjs/openauth": "catalog:",
|
||||
@@ -93,8 +103,8 @@
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "1.5.4",
|
||||
"@opentui/core": "0.1.87",
|
||||
"@opentui/solid": "0.1.87",
|
||||
"@opentui/core": "0.1.88",
|
||||
"@opentui/solid": "0.1.88",
|
||||
"@effect/platform-node": "4.0.0-beta.31",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -110,8 +120,7 @@
|
||||
"clipboardy": "4.0.0",
|
||||
"decimal.js": "10.5.0",
|
||||
"diff": "catalog:",
|
||||
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
||||
"effect": "catalog:",
|
||||
"drizzle-orm": "catalog:",
|
||||
"fuzzysort": "3.1.0",
|
||||
"glob": "13.0.5",
|
||||
"google-auth-library": "10.5.0",
|
||||
|
||||
54
packages/opencode/script/build-node.ts
Executable file
54
packages/opencode/script/build-node.ts
Executable file
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const dir = path.resolve(__dirname, "..")
|
||||
|
||||
process.chdir(dir)
|
||||
|
||||
// Load migrations from migration directories
|
||||
const migrationDirs = (
|
||||
await fs.promises.readdir(path.join(dir, "migration"), {
|
||||
withFileTypes: true,
|
||||
})
|
||||
)
|
||||
.filter((entry) => entry.isDirectory() && /^\d{4}\d{2}\d{2}\d{2}\d{2}\d{2}/.test(entry.name))
|
||||
.map((entry) => entry.name)
|
||||
.sort()
|
||||
|
||||
const migrations = await Promise.all(
|
||||
migrationDirs.map(async (name) => {
|
||||
const file = path.join(dir, "migration", name, "migration.sql")
|
||||
const sql = await Bun.file(file).text()
|
||||
const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(name)
|
||||
const timestamp = match
|
||||
? Date.UTC(
|
||||
Number(match[1]),
|
||||
Number(match[2]) - 1,
|
||||
Number(match[3]),
|
||||
Number(match[4]),
|
||||
Number(match[5]),
|
||||
Number(match[6]),
|
||||
)
|
||||
: 0
|
||||
return { sql, timestamp, name }
|
||||
}),
|
||||
)
|
||||
console.log(`Loaded ${migrations.length} migrations`)
|
||||
|
||||
await Bun.build({
|
||||
target: "node",
|
||||
entrypoints: ["./src/node.ts"],
|
||||
outdir: "./dist",
|
||||
format: "esm",
|
||||
external: ["jsonc-parser"],
|
||||
define: {
|
||||
OPENCODE_MIGRATIONS: JSON.stringify(migrations),
|
||||
},
|
||||
})
|
||||
|
||||
console.log("Build complete")
|
||||
144
packages/opencode/specs/effect-migration.md
Normal file
144
packages/opencode/specs/effect-migration.md
Normal file
@@ -0,0 +1,144 @@
|
||||
# Effect patterns
|
||||
|
||||
Practical reference for new and migrated Effect code in `packages/opencode`.
|
||||
|
||||
## Choose scope
|
||||
|
||||
Use the shared runtime for process-wide services with one lifecycle for the whole app.
|
||||
|
||||
Use `src/effect/instances.ts` for services that are created per directory or need `InstanceContext`, per-project state, or per-instance cleanup.
|
||||
|
||||
- Shared runtime: config readers, stateless helpers, global clients
|
||||
- Instance-scoped: watchers, per-project caches, session state, project-bound background work
|
||||
|
||||
Rule of thumb: if two open directories should not share one copy of the service, it belongs in `Instances`.
|
||||
|
||||
## Service shape
|
||||
|
||||
For a fully migrated module, use the public namespace directly:
|
||||
|
||||
```ts
|
||||
export namespace Foo {
|
||||
export interface Interface {
|
||||
readonly get: (id: FooID) => Effect.Effect<FooInfo, FooError>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Foo") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
return Service.of({
|
||||
get: Effect.fn("Foo.get")(function* (id) {
|
||||
return yield* ...
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(FooRepo.defaultLayer))
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Keep `Interface`, `Service`, `layer`, and `defaultLayer` on the owning namespace
|
||||
- Export `defaultLayer` only when wiring dependencies is useful
|
||||
- Use the direct namespace form once the module is fully migrated
|
||||
|
||||
## Temporary mixed-mode pattern
|
||||
|
||||
Prefer a single namespace whenever possible.
|
||||
|
||||
Use a `*Effect` namespace only when there is a real mixed-mode split, usually because a legacy boundary facade still exists or because merging everything immediately would create awkward cycles.
|
||||
|
||||
```ts
|
||||
export namespace FooEffect {
|
||||
export interface Interface {
|
||||
readonly get: (id: FooID) => Effect.Effect<Foo, FooError>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Foo") {}
|
||||
|
||||
export const layer = Layer.effect(...)
|
||||
}
|
||||
```
|
||||
|
||||
Then keep the old boundary thin:
|
||||
|
||||
```ts
|
||||
export namespace Foo {
|
||||
export function get(id: FooID) {
|
||||
return runtime.runPromise(FooEffect.Service.use((svc) => svc.get(id)))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Remove the `Effect` suffix when the boundary split is gone.
|
||||
|
||||
## Scheduled Tasks
|
||||
|
||||
For loops or periodic work, use `Effect.repeat` or `Effect.schedule` with `Effect.forkScoped` in the layer definition.
|
||||
|
||||
## Preferred Effect services
|
||||
|
||||
In effectified services, prefer yielding existing Effect services over dropping down to ad hoc platform APIs.
|
||||
|
||||
Prefer these first:
|
||||
|
||||
- `FileSystem.FileSystem` instead of raw `fs/promises` for effectful file I/O
|
||||
- `ChildProcessSpawner.ChildProcessSpawner` with `ChildProcess.make(...)` instead of custom process wrappers
|
||||
- `HttpClient.HttpClient` instead of raw `fetch`
|
||||
- `Path.Path` instead of mixing path helpers into service code when you already need a path service
|
||||
- `Config` for effect-native configuration reads
|
||||
- `Clock` / `DateTime` for time reads inside effects
|
||||
|
||||
## Child processes
|
||||
|
||||
For child process work in services, yield `ChildProcessSpawner.ChildProcessSpawner` in the layer and use `ChildProcess.make(...)`.
|
||||
|
||||
Keep shelling-out code inside the service, not in callers.
|
||||
|
||||
## Shared leaf models
|
||||
|
||||
Shared schema or model files can stay outside the service namespace when lower layers also depend on them.
|
||||
|
||||
That is fine for leaf files like `schema.ts`. Keep the service surface in the owning namespace.
|
||||
|
||||
## Migration checklist
|
||||
|
||||
Done now:
|
||||
|
||||
- [x] `AccountEffect` (mixed-mode)
|
||||
- [x] `AuthEffect` (mixed-mode)
|
||||
- [x] `TruncateEffect` (mixed-mode)
|
||||
- [x] `Question`
|
||||
- [x] `PermissionNext`
|
||||
- [x] `ProviderAuth`
|
||||
- [x] `FileWatcher`
|
||||
- [x] `FileTime`
|
||||
- [x] `Format`
|
||||
- [x] `Vcs`
|
||||
- [x] `Skill`
|
||||
- [x] `Discovery`
|
||||
- [x] `File`
|
||||
- [x] `Snapshot`
|
||||
|
||||
Still open and likely worth migrating:
|
||||
|
||||
- [ ] `Plugin`
|
||||
- [ ] `ToolRegistry`
|
||||
- [ ] `Pty`
|
||||
- [ ] `Worktree`
|
||||
- [ ] `Installation`
|
||||
- [ ] `Bus`
|
||||
- [ ] `Command`
|
||||
- [ ] `Config`
|
||||
- [ ] `Session`
|
||||
- [ ] `SessionProcessor`
|
||||
- [ ] `SessionPrompt`
|
||||
- [ ] `SessionCompaction`
|
||||
- [ ] `Provider`
|
||||
- [ ] `Project`
|
||||
- [ ] `LSP`
|
||||
- [ ] `MCP`
|
||||
@@ -108,8 +108,8 @@ const mapAccountServiceError =
|
||||
),
|
||||
)
|
||||
|
||||
export namespace AccountService {
|
||||
export interface Service {
|
||||
export namespace AccountEffect {
|
||||
export interface Interface {
|
||||
readonly active: () => Effect.Effect<Option.Option<Account>, AccountError>
|
||||
readonly list: () => Effect.Effect<Account[], AccountError>
|
||||
readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError>
|
||||
@@ -124,11 +124,11 @@ export namespace AccountService {
|
||||
readonly login: (url: string) => Effect.Effect<Login, AccountError>
|
||||
readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
|
||||
}
|
||||
}
|
||||
|
||||
export class AccountService extends ServiceMap.Service<AccountService, AccountService.Service>()("@opencode/Account") {
|
||||
static readonly layer: Layer.Layer<AccountService, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
|
||||
AccountService,
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Account") {}
|
||||
|
||||
export const layer: Layer.Layer<Service, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const repo = yield* AccountRepo
|
||||
const http = yield* HttpClient.HttpClient
|
||||
@@ -148,8 +148,6 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe
|
||||
mapAccountServiceError("HTTP request failed"),
|
||||
)
|
||||
|
||||
// Returns a usable access token for a stored account row, refreshing and
|
||||
// persisting it when the cached token has expired.
|
||||
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
if (row.token_expiry && row.token_expiry > now) return row.access_token
|
||||
@@ -218,11 +216,11 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe
|
||||
)
|
||||
})
|
||||
|
||||
const token = Effect.fn("AccountService.token")((accountID: AccountID) =>
|
||||
const token = Effect.fn("Account.token")((accountID: AccountID) =>
|
||||
resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))),
|
||||
)
|
||||
|
||||
const orgsByAccount = Effect.fn("AccountService.orgsByAccount")(function* () {
|
||||
const orgsByAccount = Effect.fn("Account.orgsByAccount")(function* () {
|
||||
const accounts = yield* repo.list()
|
||||
const [errors, results] = yield* Effect.partition(
|
||||
accounts,
|
||||
@@ -237,7 +235,7 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe
|
||||
return results
|
||||
})
|
||||
|
||||
const orgs = Effect.fn("AccountService.orgs")(function* (accountID: AccountID) {
|
||||
const orgs = Effect.fn("Account.orgs")(function* (accountID: AccountID) {
|
||||
const resolved = yield* resolveAccess(accountID)
|
||||
if (Option.isNone(resolved)) return []
|
||||
|
||||
@@ -246,7 +244,7 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe
|
||||
return yield* fetchOrgs(account.url, accessToken)
|
||||
})
|
||||
|
||||
const config = Effect.fn("AccountService.config")(function* (accountID: AccountID, orgID: OrgID) {
|
||||
const config = Effect.fn("Account.config")(function* (accountID: AccountID, orgID: OrgID) {
|
||||
const resolved = yield* resolveAccess(accountID)
|
||||
if (Option.isNone(resolved)) return Option.none()
|
||||
|
||||
@@ -270,7 +268,7 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe
|
||||
return Option.some(parsed.config)
|
||||
})
|
||||
|
||||
const login = Effect.fn("AccountService.login")(function* (server: string) {
|
||||
const login = Effect.fn("Account.login")(function* (server: string) {
|
||||
const response = yield* executeEffectOk(
|
||||
HttpClientRequest.post(`${server}/auth/device/code`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
@@ -291,7 +289,7 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe
|
||||
})
|
||||
})
|
||||
|
||||
const poll = Effect.fn("AccountService.poll")(function* (input: Login) {
|
||||
const poll = Effect.fn("Account.poll")(function* (input: Login) {
|
||||
const response = yield* executeEffectOk(
|
||||
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
@@ -337,7 +335,7 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe
|
||||
return new PollSuccess({ email: account.email })
|
||||
})
|
||||
|
||||
return AccountService.of({
|
||||
return Service.of({
|
||||
active: repo.active,
|
||||
list: repo.list,
|
||||
orgsByAccount,
|
||||
@@ -352,8 +350,5 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe
|
||||
}),
|
||||
)
|
||||
|
||||
static readonly defaultLayer = AccountService.layer.pipe(
|
||||
Layer.provide(AccountRepo.layer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
)
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
|
||||
}
|
||||
@@ -5,20 +5,20 @@ import {
|
||||
type AccountError,
|
||||
type AccessToken,
|
||||
AccountID,
|
||||
AccountService,
|
||||
AccountEffect,
|
||||
OrgID,
|
||||
} from "./service"
|
||||
} from "./effect"
|
||||
|
||||
export { AccessToken, AccountID, OrgID } from "./service"
|
||||
export { AccessToken, AccountID, OrgID } from "./effect"
|
||||
|
||||
import { runtime } from "@/effect/runtime"
|
||||
|
||||
function runSync<A>(f: (service: AccountService.Service) => Effect.Effect<A, AccountError>) {
|
||||
return runtime.runSync(AccountService.use(f))
|
||||
function runSync<A>(f: (service: AccountEffect.Interface) => Effect.Effect<A, AccountError>) {
|
||||
return runtime.runSync(AccountEffect.Service.use(f))
|
||||
}
|
||||
|
||||
function runPromise<A>(f: (service: AccountService.Service) => Effect.Effect<A, AccountError>) {
|
||||
return runtime.runPromise(AccountService.use(f))
|
||||
function runPromise<A>(f: (service: AccountEffect.Interface) => Effect.Effect<A, AccountError>) {
|
||||
return runtime.runPromise(AccountEffect.Service.use(f))
|
||||
}
|
||||
|
||||
export namespace Account {
|
||||
|
||||
@@ -64,6 +64,7 @@ export namespace Agent {
|
||||
question: "deny",
|
||||
plan_enter: "deny",
|
||||
plan_exit: "deny",
|
||||
edit: "ask",
|
||||
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
|
||||
read: {
|
||||
"*": "allow",
|
||||
|
||||
@@ -28,31 +28,31 @@ export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
|
||||
export const Info = Schema.Union([Oauth, Api, WellKnown])
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
|
||||
export class AuthServiceError extends Schema.TaggedErrorClass<AuthServiceError>()("AuthServiceError", {
|
||||
export class AuthError extends Schema.TaggedErrorClass<AuthError>()("AuthError", {
|
||||
message: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect),
|
||||
}) {}
|
||||
|
||||
const file = path.join(Global.Path.data, "auth.json")
|
||||
|
||||
const fail = (message: string) => (cause: unknown) => new AuthServiceError({ message, cause })
|
||||
const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause })
|
||||
|
||||
export namespace AuthService {
|
||||
export interface Service {
|
||||
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthServiceError>
|
||||
readonly all: () => Effect.Effect<Record<string, Info>, AuthServiceError>
|
||||
readonly set: (key: string, info: Info) => Effect.Effect<void, AuthServiceError>
|
||||
readonly remove: (key: string) => Effect.Effect<void, AuthServiceError>
|
||||
export namespace AuthEffect {
|
||||
export interface Interface {
|
||||
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthError>
|
||||
readonly all: () => Effect.Effect<Record<string, Info>, AuthError>
|
||||
readonly set: (key: string, info: Info) => Effect.Effect<void, AuthError>
|
||||
readonly remove: (key: string) => Effect.Effect<void, AuthError>
|
||||
}
|
||||
}
|
||||
|
||||
export class AuthService extends ServiceMap.Service<AuthService, AuthService.Service>()("@opencode/Auth") {
|
||||
static readonly layer = Layer.effect(
|
||||
AuthService,
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Auth") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const decode = Schema.decodeUnknownOption(Info)
|
||||
|
||||
const all = Effect.fn("AuthService.all")(() =>
|
||||
const all = Effect.fn("Auth.all")(() =>
|
||||
Effect.tryPromise({
|
||||
try: async () => {
|
||||
const data = await Filesystem.readJson<Record<string, unknown>>(file).catch(() => ({}))
|
||||
@@ -62,11 +62,11 @@ export class AuthService extends ServiceMap.Service<AuthService, AuthService.Ser
|
||||
}),
|
||||
)
|
||||
|
||||
const get = Effect.fn("AuthService.get")(function* (providerID: string) {
|
||||
const get = Effect.fn("Auth.get")(function* (providerID: string) {
|
||||
return (yield* all())[providerID]
|
||||
})
|
||||
|
||||
const set = Effect.fn("AuthService.set")(function* (key: string, info: Info) {
|
||||
const set = Effect.fn("Auth.set")(function* (key: string, info: Info) {
|
||||
const norm = key.replace(/\/+$/, "")
|
||||
const data = yield* all()
|
||||
if (norm !== key) delete data[key]
|
||||
@@ -77,7 +77,7 @@ export class AuthService extends ServiceMap.Service<AuthService, AuthService.Ser
|
||||
})
|
||||
})
|
||||
|
||||
const remove = Effect.fn("AuthService.remove")(function* (key: string) {
|
||||
const remove = Effect.fn("Auth.remove")(function* (key: string) {
|
||||
const norm = key.replace(/\/+$/, "")
|
||||
const data = yield* all()
|
||||
delete data[key]
|
||||
@@ -88,14 +88,7 @@ export class AuthService extends ServiceMap.Service<AuthService, AuthService.Ser
|
||||
})
|
||||
})
|
||||
|
||||
return AuthService.of({
|
||||
get,
|
||||
all,
|
||||
set,
|
||||
remove,
|
||||
})
|
||||
return Service.of({ get, all, set, remove })
|
||||
}),
|
||||
)
|
||||
|
||||
static readonly defaultLayer = AuthService.layer
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Effect } from "effect"
|
||||
import z from "zod"
|
||||
import { runtime } from "@/effect/runtime"
|
||||
import * as S from "./service"
|
||||
import * as S from "./effect"
|
||||
|
||||
export { OAUTH_DUMMY_KEY } from "./service"
|
||||
export { OAUTH_DUMMY_KEY } from "./effect"
|
||||
|
||||
function runPromise<A>(f: (service: S.AuthService.Service) => Effect.Effect<A, S.AuthServiceError>) {
|
||||
return runtime.runPromise(S.AuthService.use(f))
|
||||
function runPromise<A>(f: (service: S.AuthEffect.Interface) => Effect.Effect<A, S.AuthError>) {
|
||||
return runtime.runPromise(S.AuthEffect.Service.use(f))
|
||||
}
|
||||
|
||||
export namespace Auth {
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
import z from "zod"
|
||||
import { Global } from "../global"
|
||||
import { Log } from "../util/log"
|
||||
import path from "path"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { Lock } from "../util/lock"
|
||||
import { PackageRegistry } from "./registry"
|
||||
import { proxied } from "@/util/proxied"
|
||||
import { Process } from "../util/process"
|
||||
|
||||
export namespace BunProc {
|
||||
@@ -41,87 +33,4 @@ export namespace BunProc {
|
||||
export function which() {
|
||||
return process.execPath
|
||||
}
|
||||
|
||||
export const InstallFailedError = NamedError.create(
|
||||
"BunInstallFailedError",
|
||||
z.object({
|
||||
pkg: z.string(),
|
||||
version: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
export async function install(pkg: string, version = "latest") {
|
||||
// Use lock to ensure only one install at a time
|
||||
using _ = await Lock.write("bun-install")
|
||||
|
||||
const mod = path.join(Global.Path.cache, "node_modules", pkg)
|
||||
const pkgjsonPath = path.join(Global.Path.cache, "package.json")
|
||||
const parsed = await Filesystem.readJson<{ dependencies: Record<string, string> }>(pkgjsonPath).catch(async () => {
|
||||
const result = { dependencies: {} as Record<string, string> }
|
||||
await Filesystem.writeJson(pkgjsonPath, result)
|
||||
return result
|
||||
})
|
||||
if (!parsed.dependencies) parsed.dependencies = {} as Record<string, string>
|
||||
const dependencies = parsed.dependencies
|
||||
const modExists = await Filesystem.exists(mod)
|
||||
const cachedVersion = dependencies[pkg]
|
||||
|
||||
if (!modExists || !cachedVersion) {
|
||||
// continue to install
|
||||
} else if (version !== "latest" && cachedVersion === version) {
|
||||
return mod
|
||||
} else if (version === "latest") {
|
||||
const isOutdated = await PackageRegistry.isOutdated(pkg, cachedVersion, Global.Path.cache)
|
||||
if (!isOutdated) return mod
|
||||
log.info("Cached version is outdated, proceeding with install", { pkg, cachedVersion })
|
||||
}
|
||||
|
||||
// Build command arguments
|
||||
const args = [
|
||||
"add",
|
||||
"--force",
|
||||
"--exact",
|
||||
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
|
||||
...(proxied() || process.env.CI ? ["--no-cache"] : []),
|
||||
"--cwd",
|
||||
Global.Path.cache,
|
||||
pkg + "@" + version,
|
||||
]
|
||||
|
||||
// Let Bun handle registry resolution:
|
||||
// - If .npmrc files exist, Bun will use them automatically
|
||||
// - If no .npmrc files exist, Bun will default to https://registry.npmjs.org
|
||||
// - No need to pass --registry flag
|
||||
log.info("installing package using Bun's default registry resolution", {
|
||||
pkg,
|
||||
version,
|
||||
})
|
||||
|
||||
await BunProc.run(args, {
|
||||
cwd: Global.Path.cache,
|
||||
}).catch((e) => {
|
||||
throw new InstallFailedError(
|
||||
{ pkg, version },
|
||||
{
|
||||
cause: e,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
// Resolve actual version from installed package when using "latest"
|
||||
// This ensures subsequent starts use the cached version until explicitly updated
|
||||
let resolvedVersion = version
|
||||
if (version === "latest") {
|
||||
const installedPkg = await Filesystem.readJson<{ version?: string }>(path.join(mod, "package.json")).catch(
|
||||
() => null,
|
||||
)
|
||||
if (installedPkg?.version) {
|
||||
resolvedVersion = installedPkg.version
|
||||
}
|
||||
}
|
||||
|
||||
parsed.dependencies[pkg] = resolvedVersion
|
||||
await Filesystem.writeJson(pkgjsonPath, parsed)
|
||||
return mod
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import semver from "semver"
|
||||
import { Log } from "../util/log"
|
||||
import { Process } from "../util/process"
|
||||
|
||||
@@ -28,17 +27,4 @@ export namespace PackageRegistry {
|
||||
if (!value) return null
|
||||
return value
|
||||
}
|
||||
|
||||
export async function isOutdated(pkg: string, cachedVersion: string, cwd?: string): Promise<boolean> {
|
||||
const latestVersion = await info(pkg, "version", cwd)
|
||||
if (!latestVersion) {
|
||||
log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
|
||||
return false
|
||||
}
|
||||
|
||||
const isRange = /[\s^~*xX<>|=]/.test(cachedVersion)
|
||||
if (isRange) return !semver.satisfies(latestVersion, cachedVersion)
|
||||
|
||||
return semver.lt(cachedVersion, latestVersion)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { cmd } from "./cmd"
|
||||
import { Duration, Effect, Match, Option } from "effect"
|
||||
import { UI } from "../ui"
|
||||
import { runtime } from "@/effect/runtime"
|
||||
import { AccountID, AccountService, OrgID, PollExpired, type PollResult } from "@/account/service"
|
||||
import { AccountID, AccountEffect, OrgID, PollExpired, type PollResult } from "@/account/effect"
|
||||
import { type AccountError } from "@/account/schema"
|
||||
import * as Prompt from "../effect/prompt"
|
||||
import open from "open"
|
||||
@@ -17,7 +17,7 @@ const isActiveOrgChoice = (
|
||||
) => Option.isSome(active) && active.value.id === choice.accountID && active.value.active_org_id === choice.orgID
|
||||
|
||||
const loginEffect = Effect.fn("login")(function* (url: string) {
|
||||
const service = yield* AccountService
|
||||
const service = yield* AccountEffect.Service
|
||||
|
||||
yield* Prompt.intro("Log in")
|
||||
const login = yield* service.login(url)
|
||||
@@ -58,7 +58,7 @@ const loginEffect = Effect.fn("login")(function* (url: string) {
|
||||
})
|
||||
|
||||
const logoutEffect = Effect.fn("logout")(function* (email?: string) {
|
||||
const service = yield* AccountService
|
||||
const service = yield* AccountEffect.Service
|
||||
const accounts = yield* service.list()
|
||||
if (accounts.length === 0) return yield* println("Not logged in")
|
||||
|
||||
@@ -98,7 +98,7 @@ interface OrgChoice {
|
||||
}
|
||||
|
||||
const switchEffect = Effect.fn("switch")(function* () {
|
||||
const service = yield* AccountService
|
||||
const service = yield* AccountEffect.Service
|
||||
|
||||
const groups = yield* service.orgsByAccount()
|
||||
if (groups.length === 0) return yield* println("Not logged in")
|
||||
@@ -129,7 +129,7 @@ const switchEffect = Effect.fn("switch")(function* () {
|
||||
})
|
||||
|
||||
const orgsEffect = Effect.fn("orgs")(function* () {
|
||||
const service = yield* AccountService
|
||||
const service = yield* AccountEffect.Service
|
||||
|
||||
const groups = yield* service.orgsByAccount()
|
||||
if (groups.length === 0) return yield* println("No accounts found")
|
||||
|
||||
@@ -23,7 +23,7 @@ export const AcpCommand = cmd({
|
||||
process.env.OPENCODE_CLIENT = "acp"
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const opts = await resolveNetworkOptions(args)
|
||||
const server = Server.listen(opts)
|
||||
const server = await Server.listen(opts)
|
||||
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: `http://${server.hostname}:${server.port}`,
|
||||
|
||||
@@ -370,6 +370,11 @@ export const RunCommand = cmd({
|
||||
action: "deny",
|
||||
pattern: "*",
|
||||
},
|
||||
{
|
||||
permission: "edit",
|
||||
action: "allow",
|
||||
pattern: "*",
|
||||
},
|
||||
]
|
||||
|
||||
function title() {
|
||||
|
||||
@@ -15,7 +15,7 @@ export const ServeCommand = cmd({
|
||||
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
|
||||
}
|
||||
const opts = await resolveNetworkOptions(args)
|
||||
const server = Server.listen(opts)
|
||||
const server = await Server.listen(opts)
|
||||
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
|
||||
|
||||
await new Promise(() => {})
|
||||
|
||||
@@ -480,6 +480,7 @@ function App() {
|
||||
{
|
||||
title: "Toggle MCPs",
|
||||
value: "mcp.list",
|
||||
search: "toggle mcps",
|
||||
category: "Agent",
|
||||
slash: {
|
||||
name: "mcps",
|
||||
@@ -555,8 +556,9 @@ function App() {
|
||||
category: "System",
|
||||
},
|
||||
{
|
||||
title: "Toggle appearance",
|
||||
title: mode() === "dark" ? "Light mode" : "Dark mode",
|
||||
value: "theme.switch_mode",
|
||||
search: "toggle appearance",
|
||||
onSelect: (dialog) => {
|
||||
setMode(mode() === "dark" ? "light" : "dark")
|
||||
dialog.clear()
|
||||
@@ -595,6 +597,7 @@ function App() {
|
||||
},
|
||||
{
|
||||
title: "Toggle debug panel",
|
||||
search: "toggle debug",
|
||||
category: "System",
|
||||
value: "app.debug",
|
||||
onSelect: (dialog) => {
|
||||
@@ -604,6 +607,7 @@ function App() {
|
||||
},
|
||||
{
|
||||
title: "Toggle console",
|
||||
search: "toggle console",
|
||||
category: "System",
|
||||
value: "app.console",
|
||||
onSelect: (dialog) => {
|
||||
@@ -644,6 +648,7 @@ function App() {
|
||||
{
|
||||
title: terminalTitleEnabled() ? "Disable terminal title" : "Enable terminal title",
|
||||
value: "terminal.title.toggle",
|
||||
search: "toggle terminal title",
|
||||
keybind: "terminal_title_toggle",
|
||||
category: "System",
|
||||
onSelect: (dialog) => {
|
||||
@@ -659,6 +664,7 @@ function App() {
|
||||
{
|
||||
title: kv.get("animations_enabled", true) ? "Disable animations" : "Enable animations",
|
||||
value: "app.toggle.animations",
|
||||
search: "toggle animations",
|
||||
category: "System",
|
||||
onSelect: (dialog) => {
|
||||
kv.set("animations_enabled", !kv.get("animations_enabled", true))
|
||||
@@ -668,6 +674,7 @@ function App() {
|
||||
{
|
||||
title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping",
|
||||
value: "app.toggle.diffwrap",
|
||||
search: "toggle diff wrapping",
|
||||
category: "System",
|
||||
onSelect: (dialog) => {
|
||||
const current = kv.get("diff_wrap_mode", "word")
|
||||
|
||||
@@ -7,6 +7,27 @@ import { useDialog } from "@tui/ui/dialog"
|
||||
import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
|
||||
import { useKeybind } from "../context/keybind"
|
||||
import * as fuzzysort from "fuzzysort"
|
||||
import type { Provider } from "@opencode-ai/sdk/v2"
|
||||
|
||||
function pickLatest(models: [string, Provider["models"][string]][]) {
|
||||
const picks: Record<string, [string, Provider["models"][string]]> = {}
|
||||
for (const item of models) {
|
||||
const model = item[0]
|
||||
const info = item[1]
|
||||
const key = info.family ?? model
|
||||
const prev = picks[key]
|
||||
if (!prev) {
|
||||
picks[key] = item
|
||||
continue
|
||||
}
|
||||
if (info.release_date !== prev[1].release_date) {
|
||||
if (info.release_date > prev[1].release_date) picks[key] = item
|
||||
continue
|
||||
}
|
||||
if (model > prev[0]) picks[key] = item
|
||||
}
|
||||
return Object.values(picks)
|
||||
}
|
||||
|
||||
export function useConnected() {
|
||||
const sync = useSync()
|
||||
@@ -21,6 +42,7 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
const dialog = useDialog()
|
||||
const keybind = useKeybind()
|
||||
const [query, setQuery] = createSignal("")
|
||||
const [all, setAll] = createSignal(false)
|
||||
|
||||
const connected = useConnected()
|
||||
const providers = createDialogProviderOptions()
|
||||
@@ -72,8 +94,8 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
(provider) => provider.id !== "opencode",
|
||||
(provider) => provider.name,
|
||||
),
|
||||
flatMap((provider) =>
|
||||
pipe(
|
||||
flatMap((provider) => {
|
||||
const items = pipe(
|
||||
provider.models,
|
||||
entries(),
|
||||
filter(([_, info]) => info.status !== "deprecated"),
|
||||
@@ -104,8 +126,9 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
(x) => x.footer !== "Free",
|
||||
(x) => x.title,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
return items
|
||||
}),
|
||||
)
|
||||
|
||||
const popularProviders = !connected()
|
||||
@@ -154,6 +177,13 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
local.model.toggleFavorite(option.value as { providerID: string; modelID: string })
|
||||
},
|
||||
},
|
||||
{
|
||||
keybind: keybind.all.model_show_all_toggle?.[0],
|
||||
title: all() ? "Show latest only" : "Show all models",
|
||||
onTrigger: () => {
|
||||
setAll((value) => !value)
|
||||
},
|
||||
},
|
||||
]}
|
||||
onFilter={setQuery}
|
||||
flat={true}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useToast } from "../ui/toast"
|
||||
import { useKeybind } from "../context/keybind"
|
||||
import { DialogSessionList } from "./workspace/dialog-session-list"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
|
||||
async function openWorkspace(input: {
|
||||
dialog: ReturnType<typeof useDialog>
|
||||
@@ -56,7 +57,7 @@ async function openWorkspace(input: {
|
||||
return
|
||||
}
|
||||
if (result.response.status >= 500 && result.response.status < 600) {
|
||||
await Bun.sleep(1000)
|
||||
await sleep(1000)
|
||||
continue
|
||||
}
|
||||
if (!result.data) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, t, dim, fg } from "@opentui/core"
|
||||
import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes, t, dim, fg } from "@opentui/core"
|
||||
import { createEffect, createMemo, type JSX, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
|
||||
import "opentui-spinner/solid"
|
||||
import path from "path"
|
||||
@@ -13,6 +13,7 @@ import { MessageID, PartID } from "@/session/schema"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { useKeybind } from "@tui/context/keybind"
|
||||
import { usePromptHistory, type PromptInfo } from "./history"
|
||||
import { assign } from "./part"
|
||||
import { usePromptStash } from "./stash"
|
||||
import { DialogStash } from "../dialog-stash"
|
||||
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
|
||||
@@ -78,6 +79,7 @@ export function Prompt(props: PromptProps) {
|
||||
const renderer = useRenderer()
|
||||
const { theme, syntax } = useTheme()
|
||||
const kv = useKV()
|
||||
const [autoaccept, setAutoaccept] = kv.signal<"none" | "edit">("permission_auto_accept", "edit")
|
||||
|
||||
function promptModelWarning() {
|
||||
toast.show({
|
||||
@@ -171,6 +173,17 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
command.register(() => {
|
||||
return [
|
||||
{
|
||||
title: autoaccept() === "none" ? "Enable autoedit" : "Disable autoedit",
|
||||
value: "permission.auto_accept.toggle",
|
||||
search: "toggle permissions",
|
||||
keybind: "permission_auto_accept_toggle",
|
||||
category: "Agent",
|
||||
onSelect: (dialog) => {
|
||||
setAutoaccept(() => (autoaccept() === "none" ? "edit" : "none"))
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Clear prompt",
|
||||
value: "prompt.clear",
|
||||
@@ -643,10 +656,7 @@ export function Prompt(props: PromptProps) {
|
||||
type: "text",
|
||||
text: inputText,
|
||||
},
|
||||
...nonTextParts.map((x) => ({
|
||||
id: PartID.ascending(),
|
||||
...x,
|
||||
})),
|
||||
...nonTextParts.map(assign),
|
||||
],
|
||||
})
|
||||
.catch(() => {})
|
||||
@@ -936,7 +946,7 @@ export function Prompt(props: PromptProps) {
|
||||
// Normalize line endings at the boundary
|
||||
// Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste
|
||||
// Replace CRLF first, then any remaining CR
|
||||
const normalizedText = event.text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
||||
const normalizedText = decodePasteBytes(event.bytes).replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
||||
const pastedContent = normalizedText.trim()
|
||||
if (!pastedContent) {
|
||||
command.trigger("prompt.paste")
|
||||
@@ -1012,23 +1022,30 @@ export function Prompt(props: PromptProps) {
|
||||
cursorColor={theme.text}
|
||||
syntaxStyle={syntax()}
|
||||
/>
|
||||
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1}>
|
||||
<text fg={highlight()}>
|
||||
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
|
||||
</text>
|
||||
<Show when={store.mode === "normal"}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
|
||||
{local.model.parsed().model}
|
||||
</text>
|
||||
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
|
||||
<Show when={showVariant()}>
|
||||
<text fg={theme.textMuted}>·</text>
|
||||
<text>
|
||||
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
|
||||
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={highlight()}>
|
||||
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
|
||||
</text>
|
||||
<Show when={store.mode === "normal"}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
|
||||
{local.model.parsed().model}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
|
||||
<Show when={showVariant()}>
|
||||
<text fg={theme.textMuted}>·</text>
|
||||
<text>
|
||||
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={autoaccept() === "edit"}>
|
||||
<text>
|
||||
<span style={{ fg: theme.warning }}>autoedit</span>
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
16
packages/opencode/src/cli/cmd/tui/component/prompt/part.ts
Normal file
16
packages/opencode/src/cli/cmd/tui/component/prompt/part.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { PartID } from "@/session/schema"
|
||||
import type { PromptInfo } from "./history"
|
||||
|
||||
type Item = PromptInfo["parts"][number]
|
||||
|
||||
export function strip(part: Item & { id: string; messageID: string; sessionID: string }): Item {
|
||||
const { id: _id, messageID: _messageID, sessionID: _sessionID, ...rest } = part
|
||||
return rest
|
||||
}
|
||||
|
||||
export function assign(part: Item): Item & { id: PartID } {
|
||||
return {
|
||||
...part,
|
||||
id: PartID.ascending(),
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import { createSimpleContext } from "./helper"
|
||||
import type { Snapshot } from "@/snapshot"
|
||||
import { useExit } from "./exit"
|
||||
import { useArgs } from "./args"
|
||||
import { useKV } from "./kv"
|
||||
import { batch, onMount } from "solid-js"
|
||||
import { Log } from "@/util/log"
|
||||
import type { Path } from "@opencode-ai/sdk"
|
||||
@@ -106,6 +107,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
})
|
||||
|
||||
const sdk = useSDK()
|
||||
const kv = useKV()
|
||||
const [autoaccept] = kv.signal<"none" | "edit">("permission_auto_accept", "edit")
|
||||
|
||||
async function syncWorkspaces() {
|
||||
const result = await sdk.client.experimental.workspace.list().catch(() => undefined)
|
||||
@@ -136,6 +139,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
|
||||
case "permission.asked": {
|
||||
const request = event.properties
|
||||
if (autoaccept() === "edit" && request.permission === "edit") {
|
||||
sdk.client.permission.reply({
|
||||
reply: "once",
|
||||
requestID: request.id,
|
||||
})
|
||||
break
|
||||
}
|
||||
const requests = store.permission[request.sessionID]
|
||||
if (!requests) {
|
||||
setStore("permission", request.sessionID, [request])
|
||||
@@ -451,6 +461,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
get ready() {
|
||||
return store.status !== "loading"
|
||||
},
|
||||
|
||||
session: {
|
||||
get(sessionID: string) {
|
||||
const match = Binary.search(store.session, sessionID, (s) => s.id)
|
||||
|
||||
@@ -47,6 +47,7 @@ export function Home() {
|
||||
{
|
||||
title: tipsHidden() ? "Show tips" : "Hide tips",
|
||||
value: "tips.toggle",
|
||||
search: "toggle tips",
|
||||
keybind: "tips_toggle",
|
||||
category: "System",
|
||||
onSelect: (dialog) => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useSDK } from "@tui/context/sdk"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
import type { PromptInfo } from "@tui/component/prompt/history"
|
||||
import { strip } from "@tui/component/prompt/part"
|
||||
|
||||
export function DialogForkFromTimeline(props: { sessionID: string; onMove: (messageID: string) => void }) {
|
||||
const sync = useSync()
|
||||
@@ -42,7 +43,7 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess
|
||||
if (part.type === "text") {
|
||||
if (!part.synthetic) agg.input += part.text
|
||||
}
|
||||
if (part.type === "file") agg.parts.push(part)
|
||||
if (part.type === "file") agg.parts.push(strip(part))
|
||||
return agg
|
||||
},
|
||||
{ input: "", parts: [] as PromptInfo["parts"] },
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useSDK } from "@tui/context/sdk"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import { Clipboard } from "@tui/util/clipboard"
|
||||
import type { PromptInfo } from "@tui/component/prompt/history"
|
||||
import { strip } from "@tui/component/prompt/part"
|
||||
|
||||
export function DialogMessage(props: {
|
||||
messageID: string
|
||||
@@ -40,7 +41,7 @@ export function DialogMessage(props: {
|
||||
if (part.type === "text") {
|
||||
if (!part.synthetic) agg.input += part.text
|
||||
}
|
||||
if (part.type === "file") agg.parts.push(part)
|
||||
if (part.type === "file") agg.parts.push(strip(part))
|
||||
return agg
|
||||
},
|
||||
{ input: "", parts: [] as PromptInfo["parts"] },
|
||||
|
||||
@@ -568,6 +568,7 @@ export function Session() {
|
||||
{
|
||||
title: sidebarVisible() ? "Hide sidebar" : "Show sidebar",
|
||||
value: "session.sidebar.toggle",
|
||||
search: "toggle sidebar",
|
||||
keybind: "sidebar_toggle",
|
||||
category: "Session",
|
||||
onSelect: (dialog) => {
|
||||
@@ -582,6 +583,7 @@ export function Session() {
|
||||
{
|
||||
title: conceal() ? "Disable code concealment" : "Enable code concealment",
|
||||
value: "session.toggle.conceal",
|
||||
search: "toggle code concealment",
|
||||
keybind: "messages_toggle_conceal" as any,
|
||||
category: "Session",
|
||||
onSelect: (dialog) => {
|
||||
@@ -592,6 +594,7 @@ export function Session() {
|
||||
{
|
||||
title: showTimestamps() ? "Hide timestamps" : "Show timestamps",
|
||||
value: "session.toggle.timestamps",
|
||||
search: "toggle timestamps",
|
||||
category: "Session",
|
||||
slash: {
|
||||
name: "timestamps",
|
||||
@@ -605,6 +608,7 @@ export function Session() {
|
||||
{
|
||||
title: showThinking() ? "Hide thinking" : "Show thinking",
|
||||
value: "session.toggle.thinking",
|
||||
search: "toggle thinking",
|
||||
keybind: "display_thinking",
|
||||
category: "Session",
|
||||
slash: {
|
||||
@@ -619,6 +623,7 @@ export function Session() {
|
||||
{
|
||||
title: showDetails() ? "Hide tool details" : "Show tool details",
|
||||
value: "session.toggle.actions",
|
||||
search: "toggle tool details",
|
||||
keybind: "tool_details",
|
||||
category: "Session",
|
||||
onSelect: (dialog) => {
|
||||
@@ -627,8 +632,9 @@ export function Session() {
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Toggle session scrollbar",
|
||||
title: showScrollbar() ? "Hide session scrollbar" : "Show session scrollbar",
|
||||
value: "session.toggle.scrollbar",
|
||||
search: "toggle session scrollbar",
|
||||
keybind: "scrollbar_toggle",
|
||||
category: "Session",
|
||||
onSelect: (dialog) => {
|
||||
@@ -907,12 +913,12 @@ export function Session() {
|
||||
const filename = options.filename.trim()
|
||||
const filepath = path.join(exportDir, filename)
|
||||
|
||||
await Bun.write(filepath, transcript)
|
||||
await Filesystem.write(filepath, transcript)
|
||||
|
||||
// Open with EDITOR if available
|
||||
const result = await Editor.open({ value: transcript, renderer })
|
||||
if (result !== undefined) {
|
||||
await Bun.write(filepath, result)
|
||||
await Filesystem.write(filepath, result)
|
||||
}
|
||||
|
||||
toast.show({ message: `Session exported to ${filename}`, variant: "success" })
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface DialogSelectOption<T = any> {
|
||||
title: string
|
||||
value: T
|
||||
description?: string
|
||||
search?: string
|
||||
footer?: JSX.Element | string
|
||||
category?: string
|
||||
disabled?: boolean
|
||||
@@ -85,8 +86,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
// users typically search by the item name, and not its category.
|
||||
const result = fuzzysort
|
||||
.go(needle, options, {
|
||||
keys: ["title", "category"],
|
||||
scoreFn: (r) => r[0].score * 2 + r[1].score,
|
||||
keys: ["title", "category", "search"],
|
||||
scoreFn: (r) => r[0].score * 2 + r[1].score + r[2].score,
|
||||
})
|
||||
.map((x) => x.obj)
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import { upgrade } from "@/cli/upgrade"
|
||||
import { Config } from "@/config/config"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2"
|
||||
import type { BunWebSocketData } from "hono/bun"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
|
||||
@@ -38,7 +37,7 @@ GlobalBus.on("event", (event) => {
|
||||
Rpc.emit("global.event", event)
|
||||
})
|
||||
|
||||
let server: Bun.Server<BunWebSocketData> | undefined
|
||||
let server: Awaited<ReturnType<typeof Server.listen>> | undefined
|
||||
|
||||
const eventStream = {
|
||||
abort: undefined as AbortController | undefined,
|
||||
@@ -120,7 +119,7 @@ export const rpc = {
|
||||
},
|
||||
async server(input: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) {
|
||||
if (server) await server.stop(true)
|
||||
server = Server.listen(input)
|
||||
server = await Server.listen(input)
|
||||
return { url: server.url.toString() }
|
||||
},
|
||||
async checkUpgrade(input: { directory: string }) {
|
||||
@@ -143,7 +142,7 @@ export const rpc = {
|
||||
Log.Default.info("worker shutting down")
|
||||
if (eventStream.abort) eventStream.abort.abort()
|
||||
await Instance.disposeAll()
|
||||
if (server) server.stop(true)
|
||||
if (server) await server.stop(true)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ export const WebCommand = cmd({
|
||||
UI.println(UI.Style.TEXT_WARNING_BOLD + "! " + "OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
|
||||
}
|
||||
const opts = await resolveNetworkOptions(args)
|
||||
const server = Server.listen(opts)
|
||||
const server = await Server.listen(opts)
|
||||
UI.empty()
|
||||
UI.println(UI.logo(" "))
|
||||
UI.empty()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Log } from "../util/log"
|
||||
import path from "path"
|
||||
import { pathToFileURL, fileURLToPath } from "url"
|
||||
import { pathToFileURL } from "url"
|
||||
import { createRequire } from "module"
|
||||
import os from "os"
|
||||
import z from "zod"
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
} from "jsonc-parser"
|
||||
import { Instance } from "../project/instance"
|
||||
import { LSPServer } from "../lsp/server"
|
||||
import { BunProc } from "@/bun"
|
||||
import { Installation } from "@/installation"
|
||||
import { ConfigMarkdown } from "./markdown"
|
||||
import { constants, existsSync } from "fs"
|
||||
@@ -30,14 +29,11 @@ import { Bus } from "@/bus"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Event } from "../server/event"
|
||||
import { Glob } from "../util/glob"
|
||||
import { PackageRegistry } from "@/bun/registry"
|
||||
import { proxied } from "@/util/proxied"
|
||||
import { iife } from "@/util/iife"
|
||||
import { Account } from "@/account"
|
||||
import { ConfigPaths } from "./paths"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Process } from "@/util/process"
|
||||
import { Lock } from "@/util/lock"
|
||||
import { Npm } from "@/npm"
|
||||
|
||||
export namespace Config {
|
||||
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
|
||||
@@ -154,8 +150,7 @@ export namespace Config {
|
||||
|
||||
deps.push(
|
||||
iife(async () => {
|
||||
const shouldInstall = await needsInstall(dir)
|
||||
if (shouldInstall) await installDependencies(dir)
|
||||
await installDependencies(dir)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -271,6 +266,10 @@ export namespace Config {
|
||||
}
|
||||
|
||||
export async function installDependencies(dir: string) {
|
||||
if (!(await isWritable(dir))) {
|
||||
log.info("config dir is not writable, skipping dependency install", { dir })
|
||||
return
|
||||
}
|
||||
const pkg = path.join(dir, "package.json")
|
||||
const targetVersion = Installation.isLocal() ? "*" : Installation.VERSION
|
||||
|
||||
@@ -284,43 +283,14 @@ export namespace Config {
|
||||
await Filesystem.writeJson(pkg, json)
|
||||
|
||||
const gitignore = path.join(dir, ".gitignore")
|
||||
const hasGitIgnore = await Filesystem.exists(gitignore)
|
||||
if (!hasGitIgnore)
|
||||
await Filesystem.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n"))
|
||||
if (!(await Filesystem.exists(gitignore)))
|
||||
await Filesystem.write(
|
||||
gitignore,
|
||||
["node_modules", "plans", "package.json", "bun.lock", ".gitignore", "package-lock.json"].join("\n"),
|
||||
)
|
||||
|
||||
// Install any additional dependencies defined in the package.json
|
||||
// This allows local plugins and custom tools to use external packages
|
||||
using _ = await Lock.write("bun-install")
|
||||
await BunProc.run(
|
||||
[
|
||||
"install",
|
||||
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
|
||||
...(proxied() || process.env.CI ? ["--no-cache"] : []),
|
||||
],
|
||||
{ cwd: dir },
|
||||
).catch((err) => {
|
||||
if (err instanceof Process.RunFailedError) {
|
||||
const detail = {
|
||||
dir,
|
||||
cmd: err.cmd,
|
||||
code: err.code,
|
||||
stdout: err.stdout.toString(),
|
||||
stderr: err.stderr.toString(),
|
||||
}
|
||||
if (Flag.OPENCODE_STRICT_CONFIG_DEPS) {
|
||||
log.error("failed to install dependencies", detail)
|
||||
throw err
|
||||
}
|
||||
log.warn("failed to install dependencies", detail)
|
||||
return
|
||||
}
|
||||
|
||||
if (Flag.OPENCODE_STRICT_CONFIG_DEPS) {
|
||||
log.error("failed to install dependencies", { dir, error: err })
|
||||
throw err
|
||||
}
|
||||
log.warn("failed to install dependencies", { dir, error: err })
|
||||
})
|
||||
// Install any additional dependencies defined in the package.json.
|
||||
await Npm.install(dir)
|
||||
}
|
||||
|
||||
async function isWritable(dir: string) {
|
||||
@@ -332,41 +302,6 @@ export namespace Config {
|
||||
}
|
||||
}
|
||||
|
||||
export async function needsInstall(dir: string) {
|
||||
// Some config dirs may be read-only.
|
||||
// Installing deps there will fail; skip installation in that case.
|
||||
const writable = await isWritable(dir)
|
||||
if (!writable) {
|
||||
log.debug("config dir is not writable, skipping dependency install", { dir })
|
||||
return false
|
||||
}
|
||||
|
||||
const nodeModules = path.join(dir, "node_modules")
|
||||
if (!existsSync(nodeModules)) return true
|
||||
|
||||
const pkg = path.join(dir, "package.json")
|
||||
const pkgExists = await Filesystem.exists(pkg)
|
||||
if (!pkgExists) return true
|
||||
|
||||
const parsed = await Filesystem.readJson<{ dependencies?: Record<string, string> }>(pkg).catch(() => null)
|
||||
const dependencies = parsed?.dependencies ?? {}
|
||||
const depVersion = dependencies["@opencode-ai/plugin"]
|
||||
if (!depVersion) return true
|
||||
|
||||
const targetVersion = Installation.isLocal() ? "latest" : Installation.VERSION
|
||||
if (targetVersion === "latest") {
|
||||
const isOutdated = await PackageRegistry.isOutdated("@opencode-ai/plugin", depVersion, dir)
|
||||
if (!isOutdated) return false
|
||||
log.info("Cached version is outdated, proceeding with install", {
|
||||
pkg: "@opencode-ai/plugin",
|
||||
cachedVersion: depVersion,
|
||||
})
|
||||
return true
|
||||
}
|
||||
if (depVersion === targetVersion) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function rel(item: string, patterns: string[]) {
|
||||
const normalizedItem = item.replaceAll("\\", "/")
|
||||
for (const pattern of patterns) {
|
||||
@@ -818,6 +753,7 @@ export namespace Config {
|
||||
stash_delete: z.string().optional().default("ctrl+d").describe("Delete stash entry"),
|
||||
model_provider_list: z.string().optional().default("ctrl+a").describe("Open provider list from model dialog"),
|
||||
model_favorite_toggle: z.string().optional().default("ctrl+f").describe("Toggle model favorite status"),
|
||||
model_show_all_toggle: z.string().optional().default("ctrl+o").describe("Toggle showing all models"),
|
||||
session_share: z.string().optional().default("none").describe("Share current session"),
|
||||
session_unshare: z.string().optional().default("none").describe("Unshare current session"),
|
||||
session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"),
|
||||
@@ -858,7 +794,12 @@ export namespace Config {
|
||||
command_list: z.string().optional().default("ctrl+p").describe("List available commands"),
|
||||
agent_list: z.string().optional().default("<leader>a").describe("List agents"),
|
||||
agent_cycle: z.string().optional().default("tab").describe("Next agent"),
|
||||
agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"),
|
||||
agent_cycle_reverse: z.string().optional().default("none").describe("Previous agent"),
|
||||
permission_auto_accept_toggle: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("shift+tab")
|
||||
.describe("Toggle auto-accept mode for permissions"),
|
||||
variant_cycle: z.string().optional().default("ctrl+t").describe("Cycle model variants"),
|
||||
input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"),
|
||||
input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createAdaptorServer } from "@hono/node-server"
|
||||
import { Hono } from "hono"
|
||||
import { Instance } from "../../project/instance"
|
||||
import { InstanceBootstrap } from "../../project/bootstrap"
|
||||
@@ -56,10 +57,24 @@ export namespace WorkspaceServer {
|
||||
}
|
||||
|
||||
export function Listen(opts: { hostname: string; port: number }) {
|
||||
return Bun.serve({
|
||||
hostname: opts.hostname,
|
||||
port: opts.port,
|
||||
const server = createAdaptorServer({
|
||||
fetch: App().fetch,
|
||||
})
|
||||
server.listen(opts.port, opts.hostname)
|
||||
return {
|
||||
hostname: opts.hostname,
|
||||
port: opts.port,
|
||||
stop() {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
server.close((err) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import z from "zod"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { fn } from "@/util/fn"
|
||||
import { Database, eq } from "@/storage/db"
|
||||
import { Project } from "@/project/project"
|
||||
@@ -117,7 +118,7 @@ export namespace Workspace {
|
||||
const adaptor = await getAdaptor(space.type)
|
||||
const res = await adaptor.fetch(space, "/event", { method: "GET", signal: stop }).catch(() => undefined)
|
||||
if (!res || !res.ok || !res.body) {
|
||||
await Bun.sleep(1000)
|
||||
await sleep(1000)
|
||||
continue
|
||||
}
|
||||
await parseSSE(res.body, stop, (event) => {
|
||||
@@ -127,7 +128,7 @@ export namespace Workspace {
|
||||
})
|
||||
})
|
||||
// Wait 250ms and retry if SSE connection fails
|
||||
await Bun.sleep(250)
|
||||
await sleep(250)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
import { Effect, Layer, LayerMap, ServiceMap } from "effect"
|
||||
import { FileService } from "@/file"
|
||||
import { FileTimeService } from "@/file/time"
|
||||
import { FileWatcherService } from "@/file/watcher"
|
||||
import { FormatService } from "@/format"
|
||||
import { PermissionEffect } from "@/permission/service"
|
||||
import { File } from "@/file"
|
||||
import { FileTime } from "@/file/time"
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
import { Format } from "@/format"
|
||||
import { PermissionNext } from "@/permission"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { VcsService } from "@/project/vcs"
|
||||
import { ProviderAuthService } from "@/provider/auth-service"
|
||||
import { QuestionService } from "@/question/service"
|
||||
import { SkillService } from "@/skill/skill"
|
||||
import { SnapshotService } from "@/snapshot"
|
||||
import { Vcs } from "@/project/vcs"
|
||||
import { ProviderAuth } from "@/provider/auth"
|
||||
import { Question } from "@/question"
|
||||
import { Skill } from "@/skill/skill"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { InstanceContext } from "./instance-context"
|
||||
import { registerDisposer } from "./instance-registry"
|
||||
|
||||
export { InstanceContext } from "./instance-context"
|
||||
|
||||
export type InstanceServices =
|
||||
| QuestionService
|
||||
| PermissionEffect.Service
|
||||
| ProviderAuthService
|
||||
| FileWatcherService
|
||||
| VcsService
|
||||
| FileTimeService
|
||||
| FormatService
|
||||
| FileService
|
||||
| SkillService
|
||||
| SnapshotService
|
||||
| Question.Service
|
||||
| PermissionNext.Service
|
||||
| ProviderAuth.Service
|
||||
| FileWatcher.Service
|
||||
| Vcs.Service
|
||||
| FileTime.Service
|
||||
| Format.Service
|
||||
| File.Service
|
||||
| Skill.Service
|
||||
| Snapshot.Service
|
||||
|
||||
// NOTE: LayerMap only passes the key (directory string) to lookup, but we need
|
||||
// the full instance context (directory, worktree, project). We read from the
|
||||
@@ -36,16 +36,16 @@ export type InstanceServices =
|
||||
function lookup(_key: string) {
|
||||
const ctx = Layer.sync(InstanceContext, () => InstanceContext.of(Instance.current))
|
||||
return Layer.mergeAll(
|
||||
Layer.fresh(QuestionService.layer),
|
||||
Layer.fresh(PermissionEffect.layer),
|
||||
Layer.fresh(ProviderAuthService.layer),
|
||||
Layer.fresh(FileWatcherService.layer).pipe(Layer.orDie),
|
||||
Layer.fresh(VcsService.layer),
|
||||
Layer.fresh(FileTimeService.layer).pipe(Layer.orDie),
|
||||
Layer.fresh(FormatService.layer),
|
||||
Layer.fresh(FileService.layer),
|
||||
Layer.fresh(SkillService.layer),
|
||||
Layer.fresh(SnapshotService.layer),
|
||||
Layer.fresh(Question.layer),
|
||||
Layer.fresh(PermissionNext.layer),
|
||||
Layer.fresh(ProviderAuth.defaultLayer),
|
||||
Layer.fresh(FileWatcher.layer).pipe(Layer.orDie),
|
||||
Layer.fresh(Vcs.layer),
|
||||
Layer.fresh(FileTime.layer).pipe(Layer.orDie),
|
||||
Layer.fresh(Format.layer),
|
||||
Layer.fresh(File.layer),
|
||||
Layer.fresh(Skill.defaultLayer),
|
||||
Layer.fresh(Snapshot.defaultLayer),
|
||||
).pipe(Layer.provide(ctx))
|
||||
}
|
||||
|
||||
@@ -55,9 +55,7 @@ export class Instances extends ServiceMap.Service<Instances, LayerMap.LayerMap<s
|
||||
static readonly layer = Layer.effect(
|
||||
Instances,
|
||||
Effect.gen(function* () {
|
||||
const layerMap = yield* LayerMap.make(lookup, {
|
||||
idleTimeToLive: Infinity,
|
||||
})
|
||||
const layerMap = yield* LayerMap.make(lookup, { idleTimeToLive: Infinity })
|
||||
const unregister = registerDisposer((directory) => Effect.runPromise(layerMap.invalidate(directory)))
|
||||
yield* Effect.addFinalizer(() => Effect.sync(unregister))
|
||||
return Instances.of(layerMap)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||
import { AccountService } from "@/account/service"
|
||||
import { AuthService } from "@/auth/service"
|
||||
import { AccountEffect } from "@/account/effect"
|
||||
import { AuthEffect } from "@/auth/effect"
|
||||
import { Instances } from "@/effect/instances"
|
||||
import type { InstanceServices } from "@/effect/instances"
|
||||
import { TruncateEffect } from "@/tool/truncate-effect"
|
||||
@@ -8,10 +8,10 @@ import { Instance } from "@/project/instance"
|
||||
|
||||
export const runtime = ManagedRuntime.make(
|
||||
Layer.mergeAll(
|
||||
AccountService.defaultLayer, //
|
||||
AccountEffect.defaultLayer, //
|
||||
TruncateEffect.defaultLayer,
|
||||
Instances.layer,
|
||||
).pipe(Layer.provideMerge(AuthService.defaultLayer)),
|
||||
).pipe(Layer.provideMerge(AuthEffect.layer)),
|
||||
)
|
||||
|
||||
export function runPromiseInstance<A, E>(effect: Effect.Effect<A, E, InstanceServices>) {
|
||||
|
||||
@@ -1,272 +1,20 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import z from "zod"
|
||||
import { formatPatch, structuredPatch } from "diff"
|
||||
import path from "path"
|
||||
import fs from "fs"
|
||||
import ignore from "ignore"
|
||||
import { Log } from "../util/log"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Ripgrep } from "./ripgrep"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { Global } from "../global"
|
||||
import { git } from "@/util/git"
|
||||
import { Protected } from "./protected"
|
||||
import { InstanceContext } from "@/effect/instance-context"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { runPromiseInstance } from "@/effect/runtime"
|
||||
|
||||
const log = Log.create({ service: "file" })
|
||||
|
||||
const binaryExtensions = new Set([
|
||||
"exe",
|
||||
"dll",
|
||||
"pdb",
|
||||
"bin",
|
||||
"so",
|
||||
"dylib",
|
||||
"o",
|
||||
"a",
|
||||
"lib",
|
||||
"wav",
|
||||
"mp3",
|
||||
"ogg",
|
||||
"oga",
|
||||
"ogv",
|
||||
"ogx",
|
||||
"flac",
|
||||
"aac",
|
||||
"wma",
|
||||
"m4a",
|
||||
"weba",
|
||||
"mp4",
|
||||
"avi",
|
||||
"mov",
|
||||
"wmv",
|
||||
"flv",
|
||||
"webm",
|
||||
"mkv",
|
||||
"zip",
|
||||
"tar",
|
||||
"gz",
|
||||
"gzip",
|
||||
"bz",
|
||||
"bz2",
|
||||
"bzip",
|
||||
"bzip2",
|
||||
"7z",
|
||||
"rar",
|
||||
"xz",
|
||||
"lz",
|
||||
"z",
|
||||
"pdf",
|
||||
"doc",
|
||||
"docx",
|
||||
"ppt",
|
||||
"pptx",
|
||||
"xls",
|
||||
"xlsx",
|
||||
"dmg",
|
||||
"iso",
|
||||
"img",
|
||||
"vmdk",
|
||||
"ttf",
|
||||
"otf",
|
||||
"woff",
|
||||
"woff2",
|
||||
"eot",
|
||||
"sqlite",
|
||||
"db",
|
||||
"mdb",
|
||||
"apk",
|
||||
"ipa",
|
||||
"aab",
|
||||
"xapk",
|
||||
"app",
|
||||
"pkg",
|
||||
"deb",
|
||||
"rpm",
|
||||
"snap",
|
||||
"flatpak",
|
||||
"appimage",
|
||||
"msi",
|
||||
"msp",
|
||||
"jar",
|
||||
"war",
|
||||
"ear",
|
||||
"class",
|
||||
"kotlin_module",
|
||||
"dex",
|
||||
"vdex",
|
||||
"odex",
|
||||
"oat",
|
||||
"art",
|
||||
"wasm",
|
||||
"wat",
|
||||
"bc",
|
||||
"ll",
|
||||
"s",
|
||||
"ko",
|
||||
"sys",
|
||||
"drv",
|
||||
"efi",
|
||||
"rom",
|
||||
"com",
|
||||
"cmd",
|
||||
"ps1",
|
||||
"sh",
|
||||
"bash",
|
||||
"zsh",
|
||||
"fish",
|
||||
])
|
||||
|
||||
const imageExtensions = new Set([
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"gif",
|
||||
"bmp",
|
||||
"webp",
|
||||
"ico",
|
||||
"tif",
|
||||
"tiff",
|
||||
"svg",
|
||||
"svgz",
|
||||
"avif",
|
||||
"apng",
|
||||
"jxl",
|
||||
"heic",
|
||||
"heif",
|
||||
"raw",
|
||||
"cr2",
|
||||
"nef",
|
||||
"arw",
|
||||
"dng",
|
||||
"orf",
|
||||
"raf",
|
||||
"pef",
|
||||
"x3f",
|
||||
])
|
||||
|
||||
const textExtensions = new Set([
|
||||
"ts",
|
||||
"tsx",
|
||||
"mts",
|
||||
"cts",
|
||||
"mtsx",
|
||||
"ctsx",
|
||||
"js",
|
||||
"jsx",
|
||||
"mjs",
|
||||
"cjs",
|
||||
"sh",
|
||||
"bash",
|
||||
"zsh",
|
||||
"fish",
|
||||
"ps1",
|
||||
"psm1",
|
||||
"cmd",
|
||||
"bat",
|
||||
"json",
|
||||
"jsonc",
|
||||
"json5",
|
||||
"yaml",
|
||||
"yml",
|
||||
"toml",
|
||||
"md",
|
||||
"mdx",
|
||||
"txt",
|
||||
"xml",
|
||||
"html",
|
||||
"htm",
|
||||
"css",
|
||||
"scss",
|
||||
"sass",
|
||||
"less",
|
||||
"graphql",
|
||||
"gql",
|
||||
"sql",
|
||||
"ini",
|
||||
"cfg",
|
||||
"conf",
|
||||
"env",
|
||||
])
|
||||
|
||||
const textNames = new Set([
|
||||
"dockerfile",
|
||||
"makefile",
|
||||
".gitignore",
|
||||
".gitattributes",
|
||||
".editorconfig",
|
||||
".npmrc",
|
||||
".nvmrc",
|
||||
".prettierrc",
|
||||
".eslintrc",
|
||||
])
|
||||
|
||||
function isImageByExtension(filepath: string): boolean {
|
||||
const ext = path.extname(filepath).toLowerCase().slice(1)
|
||||
return imageExtensions.has(ext)
|
||||
}
|
||||
|
||||
function isTextByExtension(filepath: string): boolean {
|
||||
const ext = path.extname(filepath).toLowerCase().slice(1)
|
||||
return textExtensions.has(ext)
|
||||
}
|
||||
|
||||
function isTextByName(filepath: string): boolean {
|
||||
const name = path.basename(filepath).toLowerCase()
|
||||
return textNames.has(name)
|
||||
}
|
||||
|
||||
function getImageMimeType(filepath: string): string {
|
||||
const ext = path.extname(filepath).toLowerCase().slice(1)
|
||||
const mimeTypes: Record<string, string> = {
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
bmp: "image/bmp",
|
||||
webp: "image/webp",
|
||||
ico: "image/x-icon",
|
||||
tif: "image/tiff",
|
||||
tiff: "image/tiff",
|
||||
svg: "image/svg+xml",
|
||||
svgz: "image/svg+xml",
|
||||
avif: "image/avif",
|
||||
apng: "image/apng",
|
||||
jxl: "image/jxl",
|
||||
heic: "image/heic",
|
||||
heif: "image/heif",
|
||||
}
|
||||
return mimeTypes[ext] || "image/" + ext
|
||||
}
|
||||
|
||||
function isBinaryByExtension(filepath: string): boolean {
|
||||
const ext = path.extname(filepath).toLowerCase().slice(1)
|
||||
return binaryExtensions.has(ext)
|
||||
}
|
||||
|
||||
function isImage(mimeType: string): boolean {
|
||||
return mimeType.startsWith("image/")
|
||||
}
|
||||
|
||||
function shouldEncode(mimeType: string): boolean {
|
||||
const type = mimeType.toLowerCase()
|
||||
log.info("shouldEncode", { type })
|
||||
if (!type) return false
|
||||
|
||||
if (type.startsWith("text/")) return false
|
||||
if (type.includes("charset=")) return false
|
||||
|
||||
const parts = type.split("/", 2)
|
||||
const top = parts[0]
|
||||
|
||||
const tops = ["image", "audio", "video", "font", "model", "multipart"]
|
||||
if (tops.includes(top)) return true
|
||||
|
||||
return false
|
||||
}
|
||||
import { git } from "@/util/git"
|
||||
import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect"
|
||||
import { formatPatch, structuredPatch } from "diff"
|
||||
import fs from "fs"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import ignore from "ignore"
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
import { Global } from "../global"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Log } from "../util/log"
|
||||
import { Protected } from "./protected"
|
||||
import { Ripgrep } from "./ripgrep"
|
||||
|
||||
export namespace File {
|
||||
export const Info = z
|
||||
@@ -336,28 +84,270 @@ export namespace File {
|
||||
}
|
||||
|
||||
export function init() {
|
||||
return runPromiseInstance(FileService.use((s) => s.init()))
|
||||
return runPromiseInstance(Service.use((svc) => svc.init()))
|
||||
}
|
||||
|
||||
export async function status() {
|
||||
return runPromiseInstance(FileService.use((s) => s.status()))
|
||||
return runPromiseInstance(Service.use((svc) => svc.status()))
|
||||
}
|
||||
|
||||
export async function read(file: string): Promise<Content> {
|
||||
return runPromiseInstance(FileService.use((s) => s.read(file)))
|
||||
return runPromiseInstance(Service.use((svc) => svc.read(file)))
|
||||
}
|
||||
|
||||
export async function list(dir?: string) {
|
||||
return runPromiseInstance(FileService.use((s) => s.list(dir)))
|
||||
return runPromiseInstance(Service.use((svc) => svc.list(dir)))
|
||||
}
|
||||
|
||||
export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
|
||||
return runPromiseInstance(FileService.use((s) => s.search(input)))
|
||||
return runPromiseInstance(Service.use((svc) => svc.search(input)))
|
||||
}
|
||||
}
|
||||
|
||||
export namespace FileService {
|
||||
export interface Service {
|
||||
const log = Log.create({ service: "file" })
|
||||
|
||||
const binary = new Set([
|
||||
"exe",
|
||||
"dll",
|
||||
"pdb",
|
||||
"bin",
|
||||
"so",
|
||||
"dylib",
|
||||
"o",
|
||||
"a",
|
||||
"lib",
|
||||
"wav",
|
||||
"mp3",
|
||||
"ogg",
|
||||
"oga",
|
||||
"ogv",
|
||||
"ogx",
|
||||
"flac",
|
||||
"aac",
|
||||
"wma",
|
||||
"m4a",
|
||||
"weba",
|
||||
"mp4",
|
||||
"avi",
|
||||
"mov",
|
||||
"wmv",
|
||||
"flv",
|
||||
"webm",
|
||||
"mkv",
|
||||
"zip",
|
||||
"tar",
|
||||
"gz",
|
||||
"gzip",
|
||||
"bz",
|
||||
"bz2",
|
||||
"bzip",
|
||||
"bzip2",
|
||||
"7z",
|
||||
"rar",
|
||||
"xz",
|
||||
"lz",
|
||||
"z",
|
||||
"pdf",
|
||||
"doc",
|
||||
"docx",
|
||||
"ppt",
|
||||
"pptx",
|
||||
"xls",
|
||||
"xlsx",
|
||||
"dmg",
|
||||
"iso",
|
||||
"img",
|
||||
"vmdk",
|
||||
"ttf",
|
||||
"otf",
|
||||
"woff",
|
||||
"woff2",
|
||||
"eot",
|
||||
"sqlite",
|
||||
"db",
|
||||
"mdb",
|
||||
"apk",
|
||||
"ipa",
|
||||
"aab",
|
||||
"xapk",
|
||||
"app",
|
||||
"pkg",
|
||||
"deb",
|
||||
"rpm",
|
||||
"snap",
|
||||
"flatpak",
|
||||
"appimage",
|
||||
"msi",
|
||||
"msp",
|
||||
"jar",
|
||||
"war",
|
||||
"ear",
|
||||
"class",
|
||||
"kotlin_module",
|
||||
"dex",
|
||||
"vdex",
|
||||
"odex",
|
||||
"oat",
|
||||
"art",
|
||||
"wasm",
|
||||
"wat",
|
||||
"bc",
|
||||
"ll",
|
||||
"s",
|
||||
"ko",
|
||||
"sys",
|
||||
"drv",
|
||||
"efi",
|
||||
"rom",
|
||||
"com",
|
||||
"cmd",
|
||||
"ps1",
|
||||
"sh",
|
||||
"bash",
|
||||
"zsh",
|
||||
"fish",
|
||||
])
|
||||
|
||||
const image = new Set([
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"gif",
|
||||
"bmp",
|
||||
"webp",
|
||||
"ico",
|
||||
"tif",
|
||||
"tiff",
|
||||
"svg",
|
||||
"svgz",
|
||||
"avif",
|
||||
"apng",
|
||||
"jxl",
|
||||
"heic",
|
||||
"heif",
|
||||
"raw",
|
||||
"cr2",
|
||||
"nef",
|
||||
"arw",
|
||||
"dng",
|
||||
"orf",
|
||||
"raf",
|
||||
"pef",
|
||||
"x3f",
|
||||
])
|
||||
|
||||
const text = new Set([
|
||||
"ts",
|
||||
"tsx",
|
||||
"mts",
|
||||
"cts",
|
||||
"mtsx",
|
||||
"ctsx",
|
||||
"js",
|
||||
"jsx",
|
||||
"mjs",
|
||||
"cjs",
|
||||
"sh",
|
||||
"bash",
|
||||
"zsh",
|
||||
"fish",
|
||||
"ps1",
|
||||
"psm1",
|
||||
"cmd",
|
||||
"bat",
|
||||
"json",
|
||||
"jsonc",
|
||||
"json5",
|
||||
"yaml",
|
||||
"yml",
|
||||
"toml",
|
||||
"md",
|
||||
"mdx",
|
||||
"txt",
|
||||
"xml",
|
||||
"html",
|
||||
"htm",
|
||||
"css",
|
||||
"scss",
|
||||
"sass",
|
||||
"less",
|
||||
"graphql",
|
||||
"gql",
|
||||
"sql",
|
||||
"ini",
|
||||
"cfg",
|
||||
"conf",
|
||||
"env",
|
||||
])
|
||||
|
||||
const textName = new Set([
|
||||
"dockerfile",
|
||||
"makefile",
|
||||
".gitignore",
|
||||
".gitattributes",
|
||||
".editorconfig",
|
||||
".npmrc",
|
||||
".nvmrc",
|
||||
".prettierrc",
|
||||
".eslintrc",
|
||||
])
|
||||
|
||||
const mime: Record<string, string> = {
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
bmp: "image/bmp",
|
||||
webp: "image/webp",
|
||||
ico: "image/x-icon",
|
||||
tif: "image/tiff",
|
||||
tiff: "image/tiff",
|
||||
svg: "image/svg+xml",
|
||||
svgz: "image/svg+xml",
|
||||
avif: "image/avif",
|
||||
apng: "image/apng",
|
||||
jxl: "image/jxl",
|
||||
heic: "image/heic",
|
||||
heif: "image/heif",
|
||||
}
|
||||
|
||||
type Entry = { files: string[]; dirs: string[] }
|
||||
|
||||
const ext = (file: string) => path.extname(file).toLowerCase().slice(1)
|
||||
const name = (file: string) => path.basename(file).toLowerCase()
|
||||
const isImageByExtension = (file: string) => image.has(ext(file))
|
||||
const isTextByExtension = (file: string) => text.has(ext(file))
|
||||
const isTextByName = (file: string) => textName.has(name(file))
|
||||
const isBinaryByExtension = (file: string) => binary.has(ext(file))
|
||||
const isImage = (mimeType: string) => mimeType.startsWith("image/")
|
||||
const getImageMimeType = (file: string) => mime[ext(file)] || "image/" + ext(file)
|
||||
|
||||
function shouldEncode(mimeType: string) {
|
||||
const type = mimeType.toLowerCase()
|
||||
log.info("shouldEncode", { type })
|
||||
if (!type) return false
|
||||
if (type.startsWith("text/")) return false
|
||||
if (type.includes("charset=")) return false
|
||||
const top = type.split("/", 2)[0]
|
||||
return ["image", "audio", "video", "font", "model", "multipart"].includes(top)
|
||||
}
|
||||
|
||||
const hidden = (item: string) => {
|
||||
const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "")
|
||||
return normalized.split("/").some((part) => part.startsWith(".") && part.length > 1)
|
||||
}
|
||||
|
||||
const sortHiddenLast = (items: string[], prefer: boolean) => {
|
||||
if (prefer) return items
|
||||
const visible: string[] = []
|
||||
const hiddenItems: string[] = []
|
||||
for (const item of items) {
|
||||
if (hidden(item)) hiddenItems.push(item)
|
||||
else visible.push(item)
|
||||
}
|
||||
return [...visible, ...hiddenItems]
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
readonly status: () => Effect.Effect<File.Info[]>
|
||||
readonly read: (file: string) => Effect.Effect<File.Content>
|
||||
@@ -369,89 +359,83 @@ export namespace FileService {
|
||||
type?: "file" | "directory"
|
||||
}) => Effect.Effect<string[]>
|
||||
}
|
||||
}
|
||||
|
||||
export class FileService extends ServiceMap.Service<FileService, FileService.Service>()("@opencode/File") {
|
||||
static readonly layer = Layer.effect(
|
||||
FileService,
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/File") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* InstanceContext
|
||||
|
||||
// File cache state
|
||||
type Entry = { files: string[]; dirs: string[] }
|
||||
let cache: Entry = { files: [], dirs: [] }
|
||||
let task: Promise<void> | undefined
|
||||
|
||||
const isGlobalHome = instance.directory === Global.Path.home && instance.project.id === "global"
|
||||
|
||||
function kick() {
|
||||
if (task) return task
|
||||
task = (async () => {
|
||||
// Disable scanning if in root of file system
|
||||
if (instance.directory === path.parse(instance.directory).root) return
|
||||
const next: Entry = { files: [], dirs: [] }
|
||||
try {
|
||||
if (isGlobalHome) {
|
||||
const dirs = new Set<string>()
|
||||
const protectedNames = Protected.names()
|
||||
const scan = Effect.fn("File.scan")(function* () {
|
||||
if (instance.directory === path.parse(instance.directory).root) return
|
||||
const next: Entry = { files: [], dirs: [] }
|
||||
|
||||
const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
|
||||
const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name)
|
||||
const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
|
||||
yield* Effect.promise(async () => {
|
||||
if (isGlobalHome) {
|
||||
const dirs = new Set<string>()
|
||||
const protectedNames = Protected.names()
|
||||
const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
|
||||
const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name)
|
||||
const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
|
||||
const top = await fs.promises
|
||||
.readdir(instance.directory, { withFileTypes: true })
|
||||
.catch(() => [] as fs.Dirent[])
|
||||
|
||||
const top = await fs.promises
|
||||
.readdir(instance.directory, { withFileTypes: true })
|
||||
.catch(() => [] as fs.Dirent[])
|
||||
for (const entry of top) {
|
||||
if (!entry.isDirectory()) continue
|
||||
if (shouldIgnoreName(entry.name)) continue
|
||||
dirs.add(entry.name + "/")
|
||||
|
||||
for (const entry of top) {
|
||||
if (!entry.isDirectory()) continue
|
||||
if (shouldIgnoreName(entry.name)) continue
|
||||
dirs.add(entry.name + "/")
|
||||
|
||||
const base = path.join(instance.directory, entry.name)
|
||||
const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[])
|
||||
for (const child of children) {
|
||||
if (!child.isDirectory()) continue
|
||||
if (shouldIgnoreNested(child.name)) continue
|
||||
dirs.add(entry.name + "/" + child.name + "/")
|
||||
}
|
||||
}
|
||||
|
||||
next.dirs = Array.from(dirs).toSorted()
|
||||
} else {
|
||||
const set = new Set<string>()
|
||||
for await (const file of Ripgrep.files({ cwd: instance.directory })) {
|
||||
next.files.push(file)
|
||||
let current = file
|
||||
while (true) {
|
||||
const dir = path.dirname(current)
|
||||
if (dir === ".") break
|
||||
if (dir === current) break
|
||||
current = dir
|
||||
if (set.has(dir)) continue
|
||||
set.add(dir)
|
||||
next.dirs.push(dir + "/")
|
||||
}
|
||||
const base = path.join(instance.directory, entry.name)
|
||||
const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[])
|
||||
for (const child of children) {
|
||||
if (!child.isDirectory()) continue
|
||||
if (shouldIgnoreNested(child.name)) continue
|
||||
dirs.add(entry.name + "/" + child.name + "/")
|
||||
}
|
||||
}
|
||||
|
||||
next.dirs = Array.from(dirs).toSorted()
|
||||
} else {
|
||||
const seen = new Set<string>()
|
||||
for await (const file of Ripgrep.files({ cwd: instance.directory })) {
|
||||
next.files.push(file)
|
||||
let current = file
|
||||
while (true) {
|
||||
const dir = path.dirname(current)
|
||||
if (dir === ".") break
|
||||
if (dir === current) break
|
||||
current = dir
|
||||
if (seen.has(dir)) continue
|
||||
seen.add(dir)
|
||||
next.dirs.push(dir + "/")
|
||||
}
|
||||
}
|
||||
cache = next
|
||||
} finally {
|
||||
task = undefined
|
||||
}
|
||||
})()
|
||||
return task
|
||||
}
|
||||
})
|
||||
|
||||
const getFiles = async () => {
|
||||
void kick()
|
||||
return cache
|
||||
}
|
||||
|
||||
const init = Effect.fn("FileService.init")(function* () {
|
||||
yield* Effect.promise(() => kick())
|
||||
cache = next
|
||||
})
|
||||
|
||||
const status = Effect.fn("FileService.status")(function* () {
|
||||
const getFiles = () => cache
|
||||
|
||||
const scope = yield* Scope.Scope
|
||||
let fiber: Fiber.Fiber<void> | undefined
|
||||
|
||||
const init = Effect.fn("File.init")(function* () {
|
||||
if (!fiber) {
|
||||
fiber = yield* scan().pipe(
|
||||
Effect.catchCause(() => Effect.void),
|
||||
Effect.forkIn(scope),
|
||||
)
|
||||
}
|
||||
yield* Fiber.join(fiber)
|
||||
})
|
||||
|
||||
const status = Effect.fn("File.status")(function* () {
|
||||
if (instance.project.vcs !== "git") return []
|
||||
|
||||
return yield* Effect.promise(async () => {
|
||||
@@ -461,14 +445,13 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
|
||||
})
|
||||
).text()
|
||||
|
||||
const changedFiles: File.Info[] = []
|
||||
const changed: File.Info[] = []
|
||||
|
||||
if (diffOutput.trim()) {
|
||||
const lines = diffOutput.trim().split("\n")
|
||||
for (const line of lines) {
|
||||
const [added, removed, filepath] = line.split("\t")
|
||||
changedFiles.push({
|
||||
path: filepath,
|
||||
for (const line of diffOutput.trim().split("\n")) {
|
||||
const [added, removed, file] = line.split("\t")
|
||||
changed.push({
|
||||
path: file,
|
||||
added: added === "-" ? 0 : parseInt(added, 10),
|
||||
removed: removed === "-" ? 0 : parseInt(removed, 10),
|
||||
status: "modified",
|
||||
@@ -494,14 +477,12 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
|
||||
).text()
|
||||
|
||||
if (untrackedOutput.trim()) {
|
||||
const untrackedFiles = untrackedOutput.trim().split("\n")
|
||||
for (const filepath of untrackedFiles) {
|
||||
for (const file of untrackedOutput.trim().split("\n")) {
|
||||
try {
|
||||
const content = await Filesystem.readText(path.join(instance.directory, filepath))
|
||||
const lines = content.split("\n").length
|
||||
changedFiles.push({
|
||||
path: filepath,
|
||||
added: lines,
|
||||
const content = await Filesystem.readText(path.join(instance.directory, file))
|
||||
changed.push({
|
||||
path: file,
|
||||
added: content.split("\n").length,
|
||||
removed: 0,
|
||||
status: "added",
|
||||
})
|
||||
@@ -511,7 +492,6 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
|
||||
}
|
||||
}
|
||||
|
||||
// Get deleted files
|
||||
const deletedOutput = (
|
||||
await git(
|
||||
[
|
||||
@@ -531,50 +511,51 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
|
||||
).text()
|
||||
|
||||
if (deletedOutput.trim()) {
|
||||
const deletedFiles = deletedOutput.trim().split("\n")
|
||||
for (const filepath of deletedFiles) {
|
||||
changedFiles.push({
|
||||
path: filepath,
|
||||
for (const file of deletedOutput.trim().split("\n")) {
|
||||
changed.push({
|
||||
path: file,
|
||||
added: 0,
|
||||
removed: 0, // Could get original line count but would require another git command
|
||||
removed: 0,
|
||||
status: "deleted",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return changedFiles.map((x) => {
|
||||
const full = path.isAbsolute(x.path) ? x.path : path.join(instance.directory, x.path)
|
||||
return changed.map((item) => {
|
||||
const full = path.isAbsolute(item.path) ? item.path : path.join(instance.directory, item.path)
|
||||
return {
|
||||
...x,
|
||||
...item,
|
||||
path: path.relative(instance.directory, full),
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const read = Effect.fn("FileService.read")(function* (file: string) {
|
||||
const read = Effect.fn("File.read")(function* (file: string) {
|
||||
return yield* Effect.promise(async (): Promise<File.Content> => {
|
||||
using _ = log.time("read", { file })
|
||||
const full = path.join(instance.directory, file)
|
||||
|
||||
if (!Instance.containsPath(full)) {
|
||||
throw new Error(`Access denied: path escapes project directory`)
|
||||
throw new Error("Access denied: path escapes project directory")
|
||||
}
|
||||
|
||||
// Fast path: check extension before any filesystem operations
|
||||
if (isImageByExtension(file)) {
|
||||
if (await Filesystem.exists(full)) {
|
||||
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
|
||||
const content = buffer.toString("base64")
|
||||
const mimeType = getImageMimeType(file)
|
||||
return { type: "text", content, mimeType, encoding: "base64" }
|
||||
return {
|
||||
type: "text",
|
||||
content: buffer.toString("base64"),
|
||||
mimeType: getImageMimeType(file),
|
||||
encoding: "base64",
|
||||
}
|
||||
}
|
||||
return { type: "text", content: "" }
|
||||
}
|
||||
|
||||
const text = isTextByExtension(file) || isTextByName(file)
|
||||
const knownText = isTextByExtension(file) || isTextByName(file)
|
||||
|
||||
if (isBinaryByExtension(file) && !text) {
|
||||
if (isBinaryByExtension(file) && !knownText) {
|
||||
return { type: "binary", content: "" }
|
||||
}
|
||||
|
||||
@@ -583,7 +564,7 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
|
||||
}
|
||||
|
||||
const mimeType = Filesystem.mimeType(full)
|
||||
const encode = text ? false : shouldEncode(mimeType)
|
||||
const encode = knownText ? false : shouldEncode(mimeType)
|
||||
|
||||
if (encode && !isImage(mimeType)) {
|
||||
return { type: "binary", content: "", mimeType }
|
||||
@@ -591,8 +572,12 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
|
||||
|
||||
if (encode) {
|
||||
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
|
||||
const content = buffer.toString("base64")
|
||||
return { type: "text", content, mimeType, encoding: "base64" }
|
||||
return {
|
||||
type: "text",
|
||||
content: buffer.toString("base64"),
|
||||
mimeType,
|
||||
encoding: "base64",
|
||||
}
|
||||
}
|
||||
|
||||
const content = (await Filesystem.readText(full).catch(() => "")).trim()
|
||||
@@ -603,7 +588,9 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
|
||||
).text()
|
||||
if (!diff.trim()) {
|
||||
diff = (
|
||||
await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], { cwd: instance.directory })
|
||||
await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
|
||||
cwd: instance.directory,
|
||||
})
|
||||
).text()
|
||||
}
|
||||
if (diff.trim()) {
|
||||
@@ -612,64 +599,64 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
|
||||
context: Infinity,
|
||||
ignoreWhitespace: true,
|
||||
})
|
||||
const diff = formatPatch(patch)
|
||||
return { type: "text", content, patch, diff }
|
||||
return {
|
||||
type: "text",
|
||||
content,
|
||||
patch,
|
||||
diff: formatPatch(patch),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { type: "text", content }
|
||||
})
|
||||
})
|
||||
|
||||
const list = Effect.fn("FileService.list")(function* (dir?: string) {
|
||||
const list = Effect.fn("File.list")(function* (dir?: string) {
|
||||
return yield* Effect.promise(async () => {
|
||||
const exclude = [".git", ".DS_Store"]
|
||||
let ignored = (_: string) => false
|
||||
if (instance.project.vcs === "git") {
|
||||
const ig = ignore()
|
||||
const gitignorePath = path.join(instance.project.worktree, ".gitignore")
|
||||
if (await Filesystem.exists(gitignorePath)) {
|
||||
ig.add(await Filesystem.readText(gitignorePath))
|
||||
const gitignore = path.join(instance.project.worktree, ".gitignore")
|
||||
if (await Filesystem.exists(gitignore)) {
|
||||
ig.add(await Filesystem.readText(gitignore))
|
||||
}
|
||||
const ignorePath = path.join(instance.project.worktree, ".ignore")
|
||||
if (await Filesystem.exists(ignorePath)) {
|
||||
ig.add(await Filesystem.readText(ignorePath))
|
||||
const ignoreFile = path.join(instance.project.worktree, ".ignore")
|
||||
if (await Filesystem.exists(ignoreFile)) {
|
||||
ig.add(await Filesystem.readText(ignoreFile))
|
||||
}
|
||||
ignored = ig.ignores.bind(ig)
|
||||
}
|
||||
const resolved = dir ? path.join(instance.directory, dir) : instance.directory
|
||||
|
||||
const resolved = dir ? path.join(instance.directory, dir) : instance.directory
|
||||
if (!Instance.containsPath(resolved)) {
|
||||
throw new Error(`Access denied: path escapes project directory`)
|
||||
throw new Error("Access denied: path escapes project directory")
|
||||
}
|
||||
|
||||
const nodes: File.Node[] = []
|
||||
for (const entry of await fs.promises
|
||||
.readdir(resolved, {
|
||||
withFileTypes: true,
|
||||
})
|
||||
.catch(() => [])) {
|
||||
for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true }).catch(() => [])) {
|
||||
if (exclude.includes(entry.name)) continue
|
||||
const fullPath = path.join(resolved, entry.name)
|
||||
const relativePath = path.relative(instance.directory, fullPath)
|
||||
const absolute = path.join(resolved, entry.name)
|
||||
const file = path.relative(instance.directory, absolute)
|
||||
const type = entry.isDirectory() ? "directory" : "file"
|
||||
nodes.push({
|
||||
name: entry.name,
|
||||
path: relativePath,
|
||||
absolute: fullPath,
|
||||
path: file,
|
||||
absolute,
|
||||
type,
|
||||
ignored: ignored(type === "directory" ? relativePath + "/" : relativePath),
|
||||
ignored: ignored(type === "directory" ? file + "/" : file),
|
||||
})
|
||||
}
|
||||
|
||||
return nodes.sort((a, b) => {
|
||||
if (a.type !== b.type) {
|
||||
return a.type === "directory" ? -1 : 1
|
||||
}
|
||||
if (a.type !== b.type) return a.type === "directory" ? -1 : 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const search = Effect.fn("FileService.search")(function* (input: {
|
||||
const search = Effect.fn("File.search")(function* (input: {
|
||||
query: string
|
||||
limit?: number
|
||||
dirs?: boolean
|
||||
@@ -681,35 +668,20 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
|
||||
const kind = input.type ?? (input.dirs === false ? "file" : "all")
|
||||
log.info("search", { query, kind })
|
||||
|
||||
const result = await getFiles()
|
||||
|
||||
const hidden = (item: string) => {
|
||||
const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "")
|
||||
return normalized.split("/").some((p) => p.startsWith(".") && p.length > 1)
|
||||
}
|
||||
const result = getFiles()
|
||||
const preferHidden = query.startsWith(".") || query.includes("/.")
|
||||
const sortHiddenLast = (items: string[]) => {
|
||||
if (preferHidden) return items
|
||||
const visible: string[] = []
|
||||
const hiddenItems: string[] = []
|
||||
for (const item of items) {
|
||||
const isHidden = hidden(item)
|
||||
if (isHidden) hiddenItems.push(item)
|
||||
if (!isHidden) visible.push(item)
|
||||
}
|
||||
return [...visible, ...hiddenItems]
|
||||
}
|
||||
|
||||
if (!query) {
|
||||
if (kind === "file") return result.files.slice(0, limit)
|
||||
return sortHiddenLast(result.dirs.toSorted()).slice(0, limit)
|
||||
return sortHiddenLast(result.dirs.toSorted(), preferHidden).slice(0, limit)
|
||||
}
|
||||
|
||||
const items =
|
||||
kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs]
|
||||
|
||||
const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit
|
||||
const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((r) => r.target)
|
||||
const output = kind === "directory" ? sortHiddenLast(sorted).slice(0, limit) : sorted
|
||||
const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target)
|
||||
const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted
|
||||
|
||||
log.info("search", { query, kind, results: output.length })
|
||||
return output
|
||||
@@ -717,8 +689,7 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
|
||||
})
|
||||
|
||||
log.info("init")
|
||||
|
||||
return FileService.of({ init, status, read, list, search })
|
||||
return Service.of({ init, status, read, list, search })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,115 +1,110 @@
|
||||
import { Log } from "../util/log"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Effect, Layer, ServiceMap, Semaphore } from "effect"
|
||||
import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect"
|
||||
import { runPromiseInstance } from "@/effect/runtime"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import type { SessionID } from "@/session/schema"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Log } from "../util/log"
|
||||
|
||||
const log = Log.create({ service: "file.time" })
|
||||
export namespace FileTime {
|
||||
const log = Log.create({ service: "file.time" })
|
||||
|
||||
export namespace FileTimeService {
|
||||
export interface Service {
|
||||
export type Stamp = {
|
||||
readonly read: Date
|
||||
readonly mtime: number | undefined
|
||||
readonly ctime: number | undefined
|
||||
readonly size: number | undefined
|
||||
}
|
||||
|
||||
const stamp = Effect.fnUntraced(function* (file: string) {
|
||||
const stat = Filesystem.stat(file)
|
||||
const size = typeof stat?.size === "bigint" ? Number(stat.size) : stat?.size
|
||||
return {
|
||||
read: yield* DateTime.nowAsDate,
|
||||
mtime: stat?.mtime?.getTime(),
|
||||
ctime: stat?.ctime?.getTime(),
|
||||
size,
|
||||
}
|
||||
})
|
||||
|
||||
const session = (reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) => {
|
||||
const value = reads.get(sessionID)
|
||||
if (value) return value
|
||||
|
||||
const next = new Map<string, Stamp>()
|
||||
reads.set(sessionID, next)
|
||||
return next
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void>
|
||||
readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined>
|
||||
readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void>
|
||||
readonly withLock: <T>(filepath: string, fn: () => Promise<T>) => Effect.Effect<T>
|
||||
}
|
||||
}
|
||||
|
||||
type Stamp = {
|
||||
readonly read: Date
|
||||
readonly mtime: number | undefined
|
||||
readonly ctime: number | undefined
|
||||
readonly size: number | undefined
|
||||
}
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileTime") {}
|
||||
|
||||
function stamp(file: string): Stamp {
|
||||
const stat = Filesystem.stat(file)
|
||||
const size = typeof stat?.size === "bigint" ? Number(stat.size) : stat?.size
|
||||
return {
|
||||
read: new Date(),
|
||||
mtime: stat?.mtime?.getTime(),
|
||||
ctime: stat?.ctime?.getTime(),
|
||||
size,
|
||||
}
|
||||
}
|
||||
|
||||
function session(reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) {
|
||||
let value = reads.get(sessionID)
|
||||
if (!value) {
|
||||
value = new Map<string, Stamp>()
|
||||
reads.set(sessionID, value)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export class FileTimeService extends ServiceMap.Service<FileTimeService, FileTimeService.Service>()(
|
||||
"@opencode/FileTime",
|
||||
) {
|
||||
static readonly layer = Layer.effect(
|
||||
FileTimeService,
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
|
||||
const reads = new Map<SessionID, Map<string, Stamp>>()
|
||||
const locks = new Map<string, Semaphore.Semaphore>()
|
||||
|
||||
function getLock(filepath: string) {
|
||||
let lock = locks.get(filepath)
|
||||
if (!lock) {
|
||||
lock = Semaphore.makeUnsafe(1)
|
||||
locks.set(filepath, lock)
|
||||
}
|
||||
return lock
|
||||
const getLock = (filepath: string) => {
|
||||
const lock = locks.get(filepath)
|
||||
if (lock) return lock
|
||||
|
||||
const next = Semaphore.makeUnsafe(1)
|
||||
locks.set(filepath, next)
|
||||
return next
|
||||
}
|
||||
|
||||
return FileTimeService.of({
|
||||
read: Effect.fn("FileTimeService.read")(function* (sessionID: SessionID, file: string) {
|
||||
log.info("read", { sessionID, file })
|
||||
session(reads, sessionID).set(file, stamp(file))
|
||||
}),
|
||||
|
||||
get: Effect.fn("FileTimeService.get")(function* (sessionID: SessionID, file: string) {
|
||||
return reads.get(sessionID)?.get(file)?.read
|
||||
}),
|
||||
|
||||
assert: Effect.fn("FileTimeService.assert")(function* (sessionID: SessionID, filepath: string) {
|
||||
if (disableCheck) return
|
||||
|
||||
const time = reads.get(sessionID)?.get(filepath)
|
||||
if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
|
||||
const next = stamp(filepath)
|
||||
const changed = next.mtime !== time.mtime || next.ctime !== time.ctime || next.size !== time.size
|
||||
|
||||
if (changed) {
|
||||
throw new Error(
|
||||
`File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`,
|
||||
)
|
||||
}
|
||||
}),
|
||||
|
||||
withLock: Effect.fn("FileTimeService.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) {
|
||||
const lock = getLock(filepath)
|
||||
return yield* Effect.promise(fn).pipe(lock.withPermits(1))
|
||||
}),
|
||||
const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) {
|
||||
log.info("read", { sessionID, file })
|
||||
session(reads, sessionID).set(file, yield* stamp(file))
|
||||
})
|
||||
|
||||
const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) {
|
||||
return reads.get(sessionID)?.get(file)?.read
|
||||
})
|
||||
|
||||
const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) {
|
||||
if (disableCheck) return
|
||||
|
||||
const time = reads.get(sessionID)?.get(filepath)
|
||||
if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
|
||||
|
||||
const next = yield* stamp(filepath)
|
||||
const changed = next.mtime !== time.mtime || next.ctime !== time.ctime || next.size !== time.size
|
||||
if (!changed) return
|
||||
|
||||
throw new Error(
|
||||
`File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`,
|
||||
)
|
||||
})
|
||||
|
||||
const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) {
|
||||
return yield* Effect.promise(fn).pipe(getLock(filepath).withPermits(1))
|
||||
})
|
||||
|
||||
return Service.of({ read, get, assert, withLock })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export namespace FileTime {
|
||||
export function read(sessionID: SessionID, file: string) {
|
||||
return runPromiseInstance(FileTimeService.use((s) => s.read(sessionID, file)))
|
||||
return runPromiseInstance(Service.use((s) => s.read(sessionID, file)))
|
||||
}
|
||||
|
||||
export function get(sessionID: SessionID, file: string) {
|
||||
return runPromiseInstance(FileTimeService.use((s) => s.get(sessionID, file)))
|
||||
return runPromiseInstance(Service.use((s) => s.get(sessionID, file)))
|
||||
}
|
||||
|
||||
export async function assert(sessionID: SessionID, filepath: string) {
|
||||
return runPromiseInstance(FileTimeService.use((s) => s.assert(sessionID, filepath)))
|
||||
return runPromiseInstance(Service.use((s) => s.assert(sessionID, filepath)))
|
||||
}
|
||||
|
||||
export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
|
||||
return runPromiseInstance(FileTimeService.use((s) => s.withLock(filepath, fn)))
|
||||
return runPromiseInstance(Service.use((s) => s.withLock(filepath, fn)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,89 +1,76 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Bus } from "@/bus"
|
||||
import { InstanceContext } from "@/effect/instance-context"
|
||||
import { Instance } from "@/project/instance"
|
||||
import z from "zod"
|
||||
import { Log } from "../util/log"
|
||||
import { FileIgnore } from "./ignore"
|
||||
import { Config } from "../config/config"
|
||||
import path from "path"
|
||||
import { Cause, Effect, Layer, ServiceMap } from "effect"
|
||||
// @ts-ignore
|
||||
import { createWrapper } from "@parcel/watcher/wrapper"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import type ParcelWatcher from "@parcel/watcher"
|
||||
import { readdir } from "fs/promises"
|
||||
import { git } from "@/util/git"
|
||||
import { Protected } from "./protected"
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceContext } from "@/effect/instance-context"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Cause, Effect, Layer, ServiceMap } from "effect"
|
||||
|
||||
const SUBSCRIBE_TIMEOUT_MS = 10_000
|
||||
import { Instance } from "@/project/instance"
|
||||
import { git } from "@/util/git"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { Config } from "../config/config"
|
||||
import { FileIgnore } from "./ignore"
|
||||
import { Protected } from "./protected"
|
||||
import { Log } from "../util/log"
|
||||
|
||||
declare const OPENCODE_LIBC: string | undefined
|
||||
|
||||
const log = Log.create({ service: "file.watcher" })
|
||||
|
||||
const event = {
|
||||
Updated: BusEvent.define(
|
||||
"file.watcher.updated",
|
||||
z.object({
|
||||
file: z.string(),
|
||||
event: z.union([z.literal("add"), z.literal("change"), z.literal("unlink")]),
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
|
||||
try {
|
||||
const binding = require(
|
||||
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${OPENCODE_LIBC || "glibc"}` : ""}`,
|
||||
)
|
||||
return createWrapper(binding) as typeof import("@parcel/watcher")
|
||||
} catch (error) {
|
||||
log.error("failed to load watcher binding", { error })
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
function getBackend() {
|
||||
if (process.platform === "win32") return "windows"
|
||||
if (process.platform === "darwin") return "fs-events"
|
||||
if (process.platform === "linux") return "inotify"
|
||||
}
|
||||
|
||||
export namespace FileWatcher {
|
||||
export const Event = event
|
||||
/** Whether the native @parcel/watcher binding is available on this platform. */
|
||||
export const hasNativeBinding = () => !!watcher()
|
||||
}
|
||||
const log = Log.create({ service: "file.watcher" })
|
||||
const SUBSCRIBE_TIMEOUT_MS = 10_000
|
||||
|
||||
const init = Effect.fn("FileWatcherService.init")(function* () {})
|
||||
|
||||
export namespace FileWatcherService {
|
||||
export interface Service {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
export const Event = {
|
||||
Updated: BusEvent.define(
|
||||
"file.watcher.updated",
|
||||
z.object({
|
||||
file: z.string(),
|
||||
event: z.union([z.literal("add"), z.literal("change"), z.literal("unlink")]),
|
||||
}),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export class FileWatcherService extends ServiceMap.Service<FileWatcherService, FileWatcherService.Service>()(
|
||||
"@opencode/FileWatcher",
|
||||
) {
|
||||
static readonly layer = Layer.effect(
|
||||
FileWatcherService,
|
||||
const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
|
||||
try {
|
||||
const binding = require(
|
||||
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${OPENCODE_LIBC || "glibc"}` : ""}`,
|
||||
)
|
||||
return createWrapper(binding) as typeof import("@parcel/watcher")
|
||||
} catch (error) {
|
||||
log.error("failed to load watcher binding", { error })
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
function getBackend() {
|
||||
if (process.platform === "win32") return "windows"
|
||||
if (process.platform === "darwin") return "fs-events"
|
||||
if (process.platform === "linux") return "inotify"
|
||||
}
|
||||
|
||||
export const hasNativeBinding = () => !!watcher()
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, {}>()("@opencode/FileWatcher") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* InstanceContext
|
||||
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return FileWatcherService.of({ init })
|
||||
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return Service.of({})
|
||||
|
||||
log.info("init", { directory: instance.directory })
|
||||
|
||||
const backend = getBackend()
|
||||
if (!backend) {
|
||||
log.error("watcher backend not supported", { directory: instance.directory, platform: process.platform })
|
||||
return FileWatcherService.of({ init })
|
||||
return Service.of({})
|
||||
}
|
||||
|
||||
const w = watcher()
|
||||
if (!w) return FileWatcherService.of({ init })
|
||||
if (!w) return Service.of({})
|
||||
|
||||
log.info("watcher backend", { directory: instance.directory, platform: process.platform, backend })
|
||||
|
||||
@@ -93,9 +80,9 @@ export class FileWatcherService extends ServiceMap.Service<FileWatcherService, F
|
||||
const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => {
|
||||
if (err) return
|
||||
for (const evt of evts) {
|
||||
if (evt.type === "create") Bus.publish(event.Updated, { file: evt.path, event: "add" })
|
||||
if (evt.type === "update") Bus.publish(event.Updated, { file: evt.path, event: "change" })
|
||||
if (evt.type === "delete") Bus.publish(event.Updated, { file: evt.path, event: "unlink" })
|
||||
if (evt.type === "create") Bus.publish(Event.Updated, { file: evt.path, event: "add" })
|
||||
if (evt.type === "update") Bus.publish(Event.Updated, { file: evt.path, event: "change" })
|
||||
if (evt.type === "delete") Bus.publish(Event.Updated, { file: evt.path, event: "unlink" })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -108,7 +95,6 @@ export class FileWatcherService extends ServiceMap.Service<FileWatcherService, F
|
||||
Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
|
||||
Effect.catchCause((cause) => {
|
||||
log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) })
|
||||
// Clean up a subscription that resolves after timeout
|
||||
pending.then((s) => s.unsubscribe()).catch(() => {})
|
||||
return Effect.void
|
||||
}),
|
||||
@@ -137,11 +123,11 @@ export class FileWatcherService extends ServiceMap.Service<FileWatcherService, F
|
||||
}
|
||||
}
|
||||
|
||||
return FileWatcherService.of({ init })
|
||||
return Service.of({})
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
log.error("failed to init watcher service", { cause: Cause.pretty(cause) })
|
||||
return Effect.succeed(FileWatcherService.of({ init }))
|
||||
return Effect.succeed(Service.of({}))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,40 +1,40 @@
|
||||
import { text } from "node:stream/consumers"
|
||||
import { BunProc } from "../bun"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Process } from "../util/process"
|
||||
import { which } from "../util/which"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Npm } from "@/npm"
|
||||
|
||||
export interface Info {
|
||||
name: string
|
||||
command: string[]
|
||||
environment?: Record<string, string>
|
||||
extensions: string[]
|
||||
enabled(): Promise<boolean>
|
||||
enabled(): Promise<string[] | false>
|
||||
}
|
||||
|
||||
export const gofmt: Info = {
|
||||
name: "gofmt",
|
||||
command: ["gofmt", "-w", "$FILE"],
|
||||
extensions: [".go"],
|
||||
async enabled() {
|
||||
return which("gofmt") !== null
|
||||
const p = which("gofmt")
|
||||
if (p === null) return false
|
||||
return [p, "-w", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const mix: Info = {
|
||||
name: "mix",
|
||||
command: ["mix", "format", "$FILE"],
|
||||
extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
|
||||
async enabled() {
|
||||
return which("mix") !== null
|
||||
const p = which("mix")
|
||||
if (p === null) return false
|
||||
return [p, "format", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const prettier: Info = {
|
||||
name: "prettier",
|
||||
command: [BunProc.which(), "x", "prettier", "--write", "$FILE"],
|
||||
environment: {
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
@@ -73,8 +73,9 @@ export const prettier: Info = {
|
||||
dependencies?: Record<string, string>
|
||||
devDependencies?: Record<string, string>
|
||||
}>(item)
|
||||
if (json.dependencies?.prettier) return true
|
||||
if (json.devDependencies?.prettier) return true
|
||||
if (json.dependencies?.prettier || json.devDependencies?.prettier) {
|
||||
return [await Npm.which("prettier"), "--write", "$FILE"]
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
@@ -82,7 +83,6 @@ export const prettier: Info = {
|
||||
|
||||
export const oxfmt: Info = {
|
||||
name: "oxfmt",
|
||||
command: [BunProc.which(), "x", "oxfmt", "$FILE"],
|
||||
environment: {
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
@@ -95,8 +95,9 @@ export const oxfmt: Info = {
|
||||
dependencies?: Record<string, string>
|
||||
devDependencies?: Record<string, string>
|
||||
}>(item)
|
||||
if (json.dependencies?.oxfmt) return true
|
||||
if (json.devDependencies?.oxfmt) return true
|
||||
if (json.dependencies?.oxfmt || json.devDependencies?.oxfmt) {
|
||||
return [await Npm.which("oxfmt"), "$FILE"]
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
@@ -104,7 +105,6 @@ export const oxfmt: Info = {
|
||||
|
||||
export const biome: Info = {
|
||||
name: "biome",
|
||||
command: [BunProc.which(), "x", "@biomejs/biome", "check", "--write", "$FILE"],
|
||||
environment: {
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
@@ -141,7 +141,7 @@ export const biome: Info = {
|
||||
for (const config of configs) {
|
||||
const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree)
|
||||
if (found.length > 0) {
|
||||
return true
|
||||
return [await Npm.which("@biomejs/biome"), "check", "--write", "$FILE"]
|
||||
}
|
||||
}
|
||||
return false
|
||||
@@ -150,47 +150,49 @@ export const biome: Info = {
|
||||
|
||||
export const zig: Info = {
|
||||
name: "zig",
|
||||
command: ["zig", "fmt", "$FILE"],
|
||||
extensions: [".zig", ".zon"],
|
||||
async enabled() {
|
||||
return which("zig") !== null
|
||||
const p = which("zig")
|
||||
if (p === null) return false
|
||||
return [p, "fmt", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const clang: Info = {
|
||||
name: "clang-format",
|
||||
command: ["clang-format", "-i", "$FILE"],
|
||||
extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"],
|
||||
async enabled() {
|
||||
const items = await Filesystem.findUp(".clang-format", Instance.directory, Instance.worktree)
|
||||
return items.length > 0
|
||||
if (items.length === 0) return false
|
||||
return ["clang-format", "-i", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const ktlint: Info = {
|
||||
name: "ktlint",
|
||||
command: ["ktlint", "-F", "$FILE"],
|
||||
extensions: [".kt", ".kts"],
|
||||
async enabled() {
|
||||
return which("ktlint") !== null
|
||||
const p = which("ktlint")
|
||||
if (p === null) return false
|
||||
return [p, "-F", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const ruff: Info = {
|
||||
name: "ruff",
|
||||
command: ["ruff", "format", "$FILE"],
|
||||
extensions: [".py", ".pyi"],
|
||||
async enabled() {
|
||||
if (!which("ruff")) return false
|
||||
const p = which("ruff")
|
||||
if (p === null) return false
|
||||
const configs = ["pyproject.toml", "ruff.toml", ".ruff.toml"]
|
||||
for (const config of configs) {
|
||||
const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree)
|
||||
if (found.length > 0) {
|
||||
if (config === "pyproject.toml") {
|
||||
const content = await Filesystem.readText(found[0])
|
||||
if (content.includes("[tool.ruff]")) return true
|
||||
if (content.includes("[tool.ruff]")) return [p, "format", "$FILE"]
|
||||
} else {
|
||||
return true
|
||||
return [p, "format", "$FILE"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -199,7 +201,7 @@ export const ruff: Info = {
|
||||
const found = await Filesystem.findUp(dep, Instance.directory, Instance.worktree)
|
||||
if (found.length > 0) {
|
||||
const content = await Filesystem.readText(found[0])
|
||||
if (content.includes("ruff")) return true
|
||||
if (content.includes("ruff")) return [p, "format", "$FILE"]
|
||||
}
|
||||
}
|
||||
return false
|
||||
@@ -208,14 +210,13 @@ export const ruff: Info = {
|
||||
|
||||
export const rlang: Info = {
|
||||
name: "air",
|
||||
command: ["air", "format", "$FILE"],
|
||||
extensions: [".R"],
|
||||
async enabled() {
|
||||
const airPath = which("air")
|
||||
if (airPath == null) return false
|
||||
|
||||
try {
|
||||
const proc = Process.spawn(["air", "--help"], {
|
||||
const proc = Process.spawn([airPath, "--help"], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
@@ -227,7 +228,10 @@ export const rlang: Info = {
|
||||
const firstLine = output.split("\n")[0]
|
||||
const hasR = firstLine.includes("R language")
|
||||
const hasFormatter = firstLine.includes("formatter")
|
||||
return hasR && hasFormatter
|
||||
if (hasR && hasFormatter) {
|
||||
return [airPath, "format", "$FILE"]
|
||||
}
|
||||
return false
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
@@ -236,14 +240,14 @@ export const rlang: Info = {
|
||||
|
||||
export const uvformat: Info = {
|
||||
name: "uv",
|
||||
command: ["uv", "format", "--", "$FILE"],
|
||||
extensions: [".py", ".pyi"],
|
||||
async enabled() {
|
||||
if (await ruff.enabled()) return false
|
||||
if (which("uv") !== null) {
|
||||
const proc = Process.spawn(["uv", "format", "--help"], { stderr: "pipe", stdout: "pipe" })
|
||||
const uvPath = which("uv")
|
||||
if (uvPath !== null) {
|
||||
const proc = Process.spawn([uvPath, "format", "--help"], { stderr: "pipe", stdout: "pipe" })
|
||||
const code = await proc.exited
|
||||
return code === 0
|
||||
if (code === 0) return [uvPath, "format", "--", "$FILE"]
|
||||
}
|
||||
return false
|
||||
},
|
||||
@@ -251,108 +255,118 @@ export const uvformat: Info = {
|
||||
|
||||
export const rubocop: Info = {
|
||||
name: "rubocop",
|
||||
command: ["rubocop", "--autocorrect", "$FILE"],
|
||||
extensions: [".rb", ".rake", ".gemspec", ".ru"],
|
||||
async enabled() {
|
||||
return which("rubocop") !== null
|
||||
const path = which("rubocop")
|
||||
if (path === null) return false
|
||||
return [path, "--autocorrect", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const standardrb: Info = {
|
||||
name: "standardrb",
|
||||
command: ["standardrb", "--fix", "$FILE"],
|
||||
extensions: [".rb", ".rake", ".gemspec", ".ru"],
|
||||
async enabled() {
|
||||
return which("standardrb") !== null
|
||||
const path = which("standardrb")
|
||||
if (path === null) return false
|
||||
return [path, "--fix", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const htmlbeautifier: Info = {
|
||||
name: "htmlbeautifier",
|
||||
command: ["htmlbeautifier", "$FILE"],
|
||||
extensions: [".erb", ".html.erb"],
|
||||
async enabled() {
|
||||
return which("htmlbeautifier") !== null
|
||||
const path = which("htmlbeautifier")
|
||||
if (path === null) return false
|
||||
return [path, "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const dart: Info = {
|
||||
name: "dart",
|
||||
command: ["dart", "format", "$FILE"],
|
||||
extensions: [".dart"],
|
||||
async enabled() {
|
||||
return which("dart") !== null
|
||||
const path = which("dart")
|
||||
if (path === null) return false
|
||||
return [path, "format", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const ocamlformat: Info = {
|
||||
name: "ocamlformat",
|
||||
command: ["ocamlformat", "-i", "$FILE"],
|
||||
extensions: [".ml", ".mli"],
|
||||
async enabled() {
|
||||
if (!which("ocamlformat")) return false
|
||||
const path = which("ocamlformat")
|
||||
if (!path) return false
|
||||
const items = await Filesystem.findUp(".ocamlformat", Instance.directory, Instance.worktree)
|
||||
return items.length > 0
|
||||
if (items.length === 0) return false
|
||||
return [path, "-i", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const terraform: Info = {
|
||||
name: "terraform",
|
||||
command: ["terraform", "fmt", "$FILE"],
|
||||
extensions: [".tf", ".tfvars"],
|
||||
async enabled() {
|
||||
return which("terraform") !== null
|
||||
const path = which("terraform")
|
||||
if (path === null) return false
|
||||
return [path, "fmt", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const latexindent: Info = {
|
||||
name: "latexindent",
|
||||
command: ["latexindent", "-w", "-s", "$FILE"],
|
||||
extensions: [".tex"],
|
||||
async enabled() {
|
||||
return which("latexindent") !== null
|
||||
const path = which("latexindent")
|
||||
if (path === null) return false
|
||||
return [path, "-w", "-s", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const gleam: Info = {
|
||||
name: "gleam",
|
||||
command: ["gleam", "format", "$FILE"],
|
||||
extensions: [".gleam"],
|
||||
async enabled() {
|
||||
return which("gleam") !== null
|
||||
const path = which("gleam")
|
||||
if (path === null) return false
|
||||
return [path, "format", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const shfmt: Info = {
|
||||
name: "shfmt",
|
||||
command: ["shfmt", "-w", "$FILE"],
|
||||
extensions: [".sh", ".bash"],
|
||||
async enabled() {
|
||||
return which("shfmt") !== null
|
||||
const path = which("shfmt")
|
||||
if (path === null) return false
|
||||
return [path, "-w", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const nixfmt: Info = {
|
||||
name: "nixfmt",
|
||||
command: ["nixfmt", "$FILE"],
|
||||
extensions: [".nix"],
|
||||
async enabled() {
|
||||
return which("nixfmt") !== null
|
||||
const path = which("nixfmt")
|
||||
if (path === null) return false
|
||||
return [path, "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const rustfmt: Info = {
|
||||
name: "rustfmt",
|
||||
command: ["rustfmt", "$FILE"],
|
||||
extensions: [".rs"],
|
||||
async enabled() {
|
||||
return which("rustfmt") !== null
|
||||
const path = which("rustfmt")
|
||||
if (path === null) return false
|
||||
return [path, "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const pint: Info = {
|
||||
name: "pint",
|
||||
command: ["./vendor/bin/pint", "$FILE"],
|
||||
extensions: [".php"],
|
||||
async enabled() {
|
||||
const items = await Filesystem.findUp("composer.json", Instance.directory, Instance.worktree)
|
||||
@@ -361,8 +375,9 @@ export const pint: Info = {
|
||||
require?: Record<string, string>
|
||||
"require-dev"?: Record<string, string>
|
||||
}>(item)
|
||||
if (json.require?.["laravel/pint"]) return true
|
||||
if (json["require-dev"]?.["laravel/pint"]) return true
|
||||
if (json.require?.["laravel/pint"] || json["require-dev"]?.["laravel/pint"]) {
|
||||
return ["./vendor/bin/pint", "$FILE"]
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
@@ -370,27 +385,30 @@ export const pint: Info = {
|
||||
|
||||
export const ormolu: Info = {
|
||||
name: "ormolu",
|
||||
command: ["ormolu", "-i", "$FILE"],
|
||||
extensions: [".hs"],
|
||||
async enabled() {
|
||||
return which("ormolu") !== null
|
||||
const path = which("ormolu")
|
||||
if (path === null) return false
|
||||
return [path, "-i", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const cljfmt: Info = {
|
||||
name: "cljfmt",
|
||||
command: ["cljfmt", "fix", "--quiet", "$FILE"],
|
||||
extensions: [".clj", ".cljs", ".cljc", ".edn"],
|
||||
async enabled() {
|
||||
return which("cljfmt") !== null
|
||||
const path = which("cljfmt")
|
||||
if (path === null) return false
|
||||
return [path, "fix", "--quiet", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const dfmt: Info = {
|
||||
name: "dfmt",
|
||||
command: ["dfmt", "-i", "$FILE"],
|
||||
extensions: [".d"],
|
||||
async enabled() {
|
||||
return which("dfmt") !== null
|
||||
const path = which("dfmt")
|
||||
if (path === null) return false
|
||||
return [path, "-i", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
import { Bus } from "../bus"
|
||||
import { File } from "../file"
|
||||
import { Log } from "../util/log"
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
|
||||
import * as Formatter from "./formatter"
|
||||
import { Config } from "../config/config"
|
||||
import { mergeDeep } from "remeda"
|
||||
import z from "zod"
|
||||
import { Bus } from "../bus"
|
||||
import { Config } from "../config/config"
|
||||
import { File } from "../file"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Process } from "../util/process"
|
||||
import { InstanceContext } from "@/effect/instance-context"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { runPromiseInstance } from "@/effect/runtime"
|
||||
|
||||
const log = Log.create({ service: "format" })
|
||||
import { Log } from "../util/log"
|
||||
import * as Formatter from "./formatter"
|
||||
|
||||
export namespace Format {
|
||||
const log = Log.create({ service: "format" })
|
||||
|
||||
export const Status = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
@@ -27,135 +23,115 @@ export namespace Format {
|
||||
})
|
||||
export type Status = z.infer<typeof Status>
|
||||
|
||||
export async function init() {
|
||||
return runPromiseInstance(FormatService.use((s) => s.init()))
|
||||
const state = Instance.state(async () => {
|
||||
const cache: Record<string, string[] | false> = {}
|
||||
const cfg = await Config.get()
|
||||
|
||||
const formatters: Record<string, Formatter.Info> = {}
|
||||
if (cfg.formatter === false) {
|
||||
log.info("all formatters are disabled")
|
||||
return {
|
||||
cache,
|
||||
formatters,
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of Object.values(Formatter)) {
|
||||
formatters[item.name] = item
|
||||
}
|
||||
|
||||
for (const [name, item] of Object.entries(cfg.formatter ?? {})) {
|
||||
if (item.disabled) {
|
||||
delete formatters[name]
|
||||
continue
|
||||
}
|
||||
|
||||
const result: Formatter.Info = mergeDeep(formatters[name] ?? {}, {
|
||||
extensions: [],
|
||||
...item,
|
||||
})
|
||||
|
||||
result.enabled = async () => item.command ?? false
|
||||
result.name = name
|
||||
formatters[name] = result
|
||||
}
|
||||
|
||||
return {
|
||||
cache,
|
||||
formatters,
|
||||
}
|
||||
})
|
||||
|
||||
async function resolveCommand(item: Formatter.Info) {
|
||||
const s = await state()
|
||||
let command = s.cache[item.name]
|
||||
if (command === undefined) {
|
||||
log.info("resolving command", { name: item.name })
|
||||
command = await item.enabled()
|
||||
s.cache[item.name] = command
|
||||
}
|
||||
return command
|
||||
}
|
||||
|
||||
async function getFormatter(ext: string) {
|
||||
const formatters = await state().then((x) => x.formatters)
|
||||
const result: { info: Formatter.Info; command: string[] }[] = []
|
||||
for (const item of Object.values(formatters)) {
|
||||
if (!item.extensions.includes(ext)) continue
|
||||
const command = await resolveCommand(item)
|
||||
if (!command) continue
|
||||
log.info("enabled", { name: item.name, ext })
|
||||
result.push({ info: item, command })
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export async function status() {
|
||||
return runPromiseInstance(FormatService.use((s) => s.status()))
|
||||
const s = await state()
|
||||
const result: Status[] = []
|
||||
for (const formatter of Object.values(s.formatters)) {
|
||||
const command = await resolveCommand(formatter)
|
||||
result.push({
|
||||
name: formatter.name,
|
||||
extensions: formatter.extensions,
|
||||
enabled: !!command,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
export namespace FormatService {
|
||||
export interface Service {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
readonly status: () => Effect.Effect<Format.Status[]>
|
||||
}
|
||||
}
|
||||
export function init() {
|
||||
log.info("init")
|
||||
Bus.subscribe(File.Event.Edited, async (payload) => {
|
||||
const file = payload.properties.file
|
||||
log.info("formatting", { file })
|
||||
const ext = path.extname(file)
|
||||
|
||||
export class FormatService extends ServiceMap.Service<FormatService, FormatService.Service>()("@opencode/Format") {
|
||||
static readonly layer = Layer.effect(
|
||||
FormatService,
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* InstanceContext
|
||||
|
||||
const enabled: Record<string, boolean> = {}
|
||||
const formatters: Record<string, Formatter.Info> = {}
|
||||
|
||||
const cfg = yield* Effect.promise(() => Config.get())
|
||||
|
||||
if (cfg.formatter !== false) {
|
||||
for (const item of Object.values(Formatter)) {
|
||||
formatters[item.name] = item
|
||||
}
|
||||
for (const [name, item] of Object.entries(cfg.formatter ?? {})) {
|
||||
if (item.disabled) {
|
||||
delete formatters[name]
|
||||
continue
|
||||
}
|
||||
const result = mergeDeep(formatters[name] ?? {}, {
|
||||
command: [],
|
||||
extensions: [],
|
||||
...item,
|
||||
}) as Formatter.Info
|
||||
|
||||
if (result.command.length === 0) continue
|
||||
|
||||
result.enabled = async () => true
|
||||
result.name = name
|
||||
formatters[name] = result
|
||||
}
|
||||
} else {
|
||||
log.info("all formatters are disabled")
|
||||
}
|
||||
|
||||
async function isEnabled(item: Formatter.Info) {
|
||||
let status = enabled[item.name]
|
||||
if (status === undefined) {
|
||||
status = await item.enabled()
|
||||
enabled[item.name] = status
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
async function getFormatter(ext: string) {
|
||||
const result = []
|
||||
for (const item of Object.values(formatters)) {
|
||||
log.info("checking", { name: item.name, ext })
|
||||
if (!item.extensions.includes(ext)) continue
|
||||
if (!(await isEnabled(item))) continue
|
||||
log.info("enabled", { name: item.name, ext })
|
||||
result.push(item)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const unsubscribe = Bus.subscribe(
|
||||
File.Event.Edited,
|
||||
Instance.bind(async (payload) => {
|
||||
const file = payload.properties.file
|
||||
log.info("formatting", { file })
|
||||
const ext = path.extname(file)
|
||||
|
||||
for (const item of await getFormatter(ext)) {
|
||||
log.info("running", { command: item.command })
|
||||
try {
|
||||
const proc = Process.spawn(
|
||||
item.command.map((x) => x.replace("$FILE", file)),
|
||||
{
|
||||
cwd: instance.directory,
|
||||
env: { ...process.env, ...item.environment },
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
},
|
||||
)
|
||||
const exit = await proc.exited
|
||||
if (exit !== 0)
|
||||
log.error("failed", {
|
||||
command: item.command,
|
||||
...item.environment,
|
||||
})
|
||||
} catch (error) {
|
||||
log.error("failed to format file", {
|
||||
error,
|
||||
command: item.command,
|
||||
...item.environment,
|
||||
file,
|
||||
})
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
yield* Effect.addFinalizer(() => Effect.sync(unsubscribe))
|
||||
log.info("init")
|
||||
|
||||
const init = Effect.fn("FormatService.init")(function* () {})
|
||||
|
||||
const status = Effect.fn("FormatService.status")(function* () {
|
||||
const result: Format.Status[] = []
|
||||
for (const formatter of Object.values(formatters)) {
|
||||
const isOn = yield* Effect.promise(() => isEnabled(formatter))
|
||||
result.push({
|
||||
name: formatter.name,
|
||||
extensions: formatter.extensions,
|
||||
enabled: isOn,
|
||||
for (const { info, command } of await getFormatter(ext)) {
|
||||
const replaced = command.map((x) => x.replace("$FILE", file))
|
||||
log.info("running", { replaced })
|
||||
try {
|
||||
const proc = Process.spawn(replaced, {
|
||||
cwd: Instance.directory,
|
||||
env: { ...process.env, ...info.environment },
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
})
|
||||
const exit = await proc.exited
|
||||
if (exit !== 0)
|
||||
log.error("failed", {
|
||||
command,
|
||||
...info.environment,
|
||||
})
|
||||
} catch (error) {
|
||||
log.error("failed to format file", {
|
||||
error,
|
||||
command,
|
||||
...info.environment,
|
||||
file,
|
||||
})
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
return FormatService.of({ init, status })
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export namespace Global {
|
||||
return process.env.OPENCODE_TEST_HOME || os.homedir()
|
||||
},
|
||||
data,
|
||||
bin: path.join(data, "bin"),
|
||||
bin: path.join(cache, "bin"),
|
||||
log: path.join(data, "log"),
|
||||
cache,
|
||||
config,
|
||||
|
||||
@@ -3,7 +3,6 @@ import path from "path"
|
||||
import os from "os"
|
||||
import { Global } from "../global"
|
||||
import { Log } from "../util/log"
|
||||
import { BunProc } from "../bun"
|
||||
import { text } from "node:stream/consumers"
|
||||
import fs from "fs/promises"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
@@ -13,6 +12,7 @@ import { Archive } from "../util/archive"
|
||||
import { Process } from "../util/process"
|
||||
import { which } from "../util/which"
|
||||
import { Module } from "@opencode-ai/util/module"
|
||||
import { Npm } from "@/npm"
|
||||
|
||||
const spawn = ((cmd, args, opts) => {
|
||||
if (Array.isArray(args)) return launch(cmd, [...args], { ...(opts ?? {}), windowsHide: true })
|
||||
@@ -107,7 +107,7 @@ export namespace LSPServer {
|
||||
const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory)
|
||||
log.info("typescript server", { tsserver })
|
||||
if (!tsserver) return
|
||||
const proc = spawn(BunProc.which(), ["x", "typescript-language-server", "--stdio"], {
|
||||
const proc = spawn(await Npm.which("typescript-language-server"), ["--stdio"], {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -133,29 +133,8 @@ export namespace LSPServer {
|
||||
let binary = which("vue-language-server")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
const js = path.join(
|
||||
Global.Path.bin,
|
||||
"node_modules",
|
||||
"@vue",
|
||||
"language-server",
|
||||
"bin",
|
||||
"vue-language-server.js",
|
||||
)
|
||||
if (!(await Filesystem.exists(js))) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
await Process.spawn([BunProc.which(), "install", "@vue/language-server"], {
|
||||
cwd: Global.Path.bin,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
stdin: "pipe",
|
||||
}).exited
|
||||
}
|
||||
binary = BunProc.which()
|
||||
args.push("run", js)
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
binary = await Npm.which("@vue/language-server")
|
||||
}
|
||||
args.push("--stdio")
|
||||
const proc = spawn(binary, args, {
|
||||
@@ -218,7 +197,7 @@ export namespace LSPServer {
|
||||
log.info("installed VS Code ESLint server", { serverPath })
|
||||
}
|
||||
|
||||
const proc = spawn(BunProc.which(), [serverPath, "--stdio"], {
|
||||
const proc = spawn(await Npm.which("tsx"), [serverPath, "--stdio"], {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -349,8 +328,8 @@ export namespace LSPServer {
|
||||
if (!bin) {
|
||||
const resolved = Module.resolve("biome", root)
|
||||
if (!resolved) return
|
||||
bin = BunProc.which()
|
||||
args = ["x", "biome", "lsp-proxy", "--stdio"]
|
||||
bin = await Npm.which("biome")
|
||||
args = ["lsp-proxy", "--stdio"]
|
||||
}
|
||||
|
||||
const proc = spawn(bin, args, {
|
||||
@@ -376,9 +355,7 @@ export namespace LSPServer {
|
||||
},
|
||||
extensions: [".go"],
|
||||
async spawn(root) {
|
||||
let bin = which("gopls", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
let bin = which("gopls")
|
||||
if (!bin) {
|
||||
if (!which("go")) return
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
@@ -413,9 +390,7 @@ export namespace LSPServer {
|
||||
root: NearestRoot(["Gemfile"]),
|
||||
extensions: [".rb", ".rake", ".gemspec", ".ru"],
|
||||
async spawn(root) {
|
||||
let bin = which("rubocop", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
let bin = which("rubocop")
|
||||
if (!bin) {
|
||||
const ruby = which("ruby")
|
||||
const gem = which("gem")
|
||||
@@ -520,19 +495,8 @@ export namespace LSPServer {
|
||||
let binary = which("pyright-langserver")
|
||||
const args = []
|
||||
if (!binary) {
|
||||
const js = path.join(Global.Path.bin, "node_modules", "pyright", "dist", "pyright-langserver.js")
|
||||
if (!(await Filesystem.exists(js))) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
await Process.spawn([BunProc.which(), "install", "pyright"], {
|
||||
cwd: Global.Path.bin,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
}).exited
|
||||
}
|
||||
binary = BunProc.which()
|
||||
args.push(...["run", js])
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
binary = await Npm.which("pyright")
|
||||
}
|
||||
args.push("--stdio")
|
||||
|
||||
@@ -634,9 +598,7 @@ export namespace LSPServer {
|
||||
extensions: [".zig", ".zon"],
|
||||
root: NearestRoot(["build.zig"]),
|
||||
async spawn(root) {
|
||||
let bin = which("zls", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
let bin = which("zls")
|
||||
|
||||
if (!bin) {
|
||||
const zig = which("zig")
|
||||
@@ -746,9 +708,7 @@ export namespace LSPServer {
|
||||
root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]),
|
||||
extensions: [".cs"],
|
||||
async spawn(root) {
|
||||
let bin = which("csharp-ls", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
let bin = which("csharp-ls")
|
||||
if (!bin) {
|
||||
if (!which("dotnet")) {
|
||||
log.error(".NET SDK is required to install csharp-ls")
|
||||
@@ -785,9 +745,7 @@ export namespace LSPServer {
|
||||
root: NearestRoot([".slnx", ".sln", ".fsproj", "global.json"]),
|
||||
extensions: [".fs", ".fsi", ".fsx", ".fsscript"],
|
||||
async spawn(root) {
|
||||
let bin = which("fsautocomplete", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
let bin = which("fsautocomplete")
|
||||
if (!bin) {
|
||||
if (!which("dotnet")) {
|
||||
log.error(".NET SDK is required to install fsautocomplete")
|
||||
@@ -1053,22 +1011,8 @@ export namespace LSPServer {
|
||||
let binary = which("svelteserver")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
const js = path.join(Global.Path.bin, "node_modules", "svelte-language-server", "bin", "server.js")
|
||||
if (!(await Filesystem.exists(js))) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
await Process.spawn([BunProc.which(), "install", "svelte-language-server"], {
|
||||
cwd: Global.Path.bin,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
stdin: "pipe",
|
||||
}).exited
|
||||
}
|
||||
binary = BunProc.which()
|
||||
args.push("run", js)
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
binary = await Npm.which("svelte-language-server")
|
||||
}
|
||||
args.push("--stdio")
|
||||
const proc = spawn(binary, args, {
|
||||
@@ -1100,22 +1044,8 @@ export namespace LSPServer {
|
||||
let binary = which("astro-ls")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
const js = path.join(Global.Path.bin, "node_modules", "@astrojs", "language-server", "bin", "nodeServer.js")
|
||||
if (!(await Filesystem.exists(js))) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
await Process.spawn([BunProc.which(), "install", "@astrojs/language-server"], {
|
||||
cwd: Global.Path.bin,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
stdin: "pipe",
|
||||
}).exited
|
||||
}
|
||||
binary = BunProc.which()
|
||||
args.push("run", js)
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
binary = await Npm.which("@astrojs/language-server")
|
||||
}
|
||||
args.push("--stdio")
|
||||
const proc = spawn(binary, args, {
|
||||
@@ -1364,31 +1294,8 @@ export namespace LSPServer {
|
||||
let binary = which("yaml-language-server")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
const js = path.join(
|
||||
Global.Path.bin,
|
||||
"node_modules",
|
||||
"yaml-language-server",
|
||||
"out",
|
||||
"server",
|
||||
"src",
|
||||
"server.js",
|
||||
)
|
||||
const exists = await Filesystem.exists(js)
|
||||
if (!exists) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
await Process.spawn([BunProc.which(), "install", "yaml-language-server"], {
|
||||
cwd: Global.Path.bin,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
stdin: "pipe",
|
||||
}).exited
|
||||
}
|
||||
binary = BunProc.which()
|
||||
args.push("run", js)
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
binary = await Npm.which("yaml-language-server")
|
||||
}
|
||||
args.push("--stdio")
|
||||
const proc = spawn(binary, args, {
|
||||
@@ -1417,9 +1324,7 @@ export namespace LSPServer {
|
||||
]),
|
||||
extensions: [".lua"],
|
||||
async spawn(root) {
|
||||
let bin = which("lua-language-server", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
let bin = which("lua-language-server")
|
||||
|
||||
if (!bin) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
@@ -1555,22 +1460,8 @@ export namespace LSPServer {
|
||||
let binary = which("intelephense")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
const js = path.join(Global.Path.bin, "node_modules", "intelephense", "lib", "intelephense.js")
|
||||
if (!(await Filesystem.exists(js))) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
await Process.spawn([BunProc.which(), "install", "intelephense"], {
|
||||
cwd: Global.Path.bin,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
stdin: "pipe",
|
||||
}).exited
|
||||
}
|
||||
binary = BunProc.which()
|
||||
args.push("run", js)
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
binary = await Npm.which("intelephense")
|
||||
}
|
||||
args.push("--stdio")
|
||||
const proc = spawn(binary, args, {
|
||||
@@ -1652,22 +1543,8 @@ export namespace LSPServer {
|
||||
let binary = which("bash-language-server")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
const js = path.join(Global.Path.bin, "node_modules", "bash-language-server", "out", "cli.js")
|
||||
if (!(await Filesystem.exists(js))) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
await Process.spawn([BunProc.which(), "install", "bash-language-server"], {
|
||||
cwd: Global.Path.bin,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
stdin: "pipe",
|
||||
}).exited
|
||||
}
|
||||
binary = BunProc.which()
|
||||
args.push("run", js)
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
binary = await Npm.which("bash-language-server")
|
||||
}
|
||||
args.push("start")
|
||||
const proc = spawn(binary, args, {
|
||||
@@ -1688,9 +1565,7 @@ export namespace LSPServer {
|
||||
extensions: [".tf", ".tfvars"],
|
||||
root: NearestRoot([".terraform.lock.hcl", "terraform.tfstate", "*.tf"]),
|
||||
async spawn(root) {
|
||||
let bin = which("terraform-ls", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
let bin = which("terraform-ls")
|
||||
|
||||
if (!bin) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
@@ -1771,9 +1646,7 @@ export namespace LSPServer {
|
||||
extensions: [".tex", ".bib"],
|
||||
root: NearestRoot([".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"]),
|
||||
async spawn(root) {
|
||||
let bin = which("texlab", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
let bin = which("texlab")
|
||||
|
||||
if (!bin) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
@@ -1864,22 +1737,8 @@ export namespace LSPServer {
|
||||
let binary = which("docker-langserver")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
const js = path.join(Global.Path.bin, "node_modules", "dockerfile-language-server-nodejs", "lib", "server.js")
|
||||
if (!(await Filesystem.exists(js))) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
await Process.spawn([BunProc.which(), "install", "dockerfile-language-server-nodejs"], {
|
||||
cwd: Global.Path.bin,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
stdin: "pipe",
|
||||
}).exited
|
||||
}
|
||||
binary = BunProc.which()
|
||||
args.push("run", js)
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
binary = await Npm.which("dockerfile-language-server-nodejs")
|
||||
}
|
||||
args.push("--stdio")
|
||||
const proc = spawn(binary, args, {
|
||||
@@ -1970,9 +1829,7 @@ export namespace LSPServer {
|
||||
extensions: [".typ", ".typc"],
|
||||
root: NearestRoot(["typst.toml"]),
|
||||
async spawn(root) {
|
||||
let bin = which("tinymist", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
let bin = which("tinymist")
|
||||
|
||||
if (!bin) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@modelcontextprotocol/sdk/types.js"
|
||||
import { Config } from "../config/config"
|
||||
import { Log } from "../util/log"
|
||||
import { Process } from "../util/process"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import z from "zod/v4"
|
||||
import { Instance } from "../project/instance"
|
||||
@@ -166,14 +167,10 @@ export namespace MCP {
|
||||
const queue = [pid]
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!
|
||||
const proc = Bun.spawn(["pgrep", "-P", String(current)], { stdout: "pipe", stderr: "pipe" })
|
||||
const [code, out] = await Promise.all([proc.exited, new Response(proc.stdout).text()]).catch(
|
||||
() => [-1, ""] as const,
|
||||
)
|
||||
if (code !== 0) continue
|
||||
for (const tok of out.trim().split(/\s+/)) {
|
||||
const lines = await Process.lines(["pgrep", "-P", String(current)], { nothrow: true })
|
||||
for (const tok of lines) {
|
||||
const cpid = parseInt(tok, 10)
|
||||
if (!isNaN(cpid) && pids.indexOf(cpid) === -1) {
|
||||
if (!isNaN(cpid) && !pids.includes(cpid)) {
|
||||
pids.push(cpid)
|
||||
queue.push(cpid)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createConnection } from "net"
|
||||
import { createServer } from "http"
|
||||
import { Log } from "../util/log"
|
||||
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "./oauth-provider"
|
||||
|
||||
@@ -52,11 +53,74 @@ interface PendingAuth {
|
||||
}
|
||||
|
||||
export namespace McpOAuthCallback {
|
||||
let server: ReturnType<typeof Bun.serve> | undefined
|
||||
let server: ReturnType<typeof createServer> | undefined
|
||||
const pendingAuths = new Map<string, PendingAuth>()
|
||||
|
||||
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
function handleRequest(req: import("http").IncomingMessage, res: import("http").ServerResponse) {
|
||||
const url = new URL(req.url || "/", `http://localhost:${OAUTH_CALLBACK_PORT}`)
|
||||
|
||||
if (url.pathname !== OAUTH_CALLBACK_PATH) {
|
||||
res.writeHead(404)
|
||||
res.end("Not found")
|
||||
return
|
||||
}
|
||||
|
||||
const code = url.searchParams.get("code")
|
||||
const state = url.searchParams.get("state")
|
||||
const error = url.searchParams.get("error")
|
||||
const errorDescription = url.searchParams.get("error_description")
|
||||
|
||||
log.info("received oauth callback", { hasCode: !!code, state, error })
|
||||
|
||||
// Enforce state parameter presence
|
||||
if (!state) {
|
||||
const errorMsg = "Missing required state parameter - potential CSRF attack"
|
||||
log.error("oauth callback missing state parameter", { url: url.toString() })
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const errorMsg = errorDescription || error
|
||||
if (pendingAuths.has(state)) {
|
||||
const pending = pendingAuths.get(state)!
|
||||
clearTimeout(pending.timeout)
|
||||
pendingAuths.delete(state)
|
||||
pending.reject(new Error(errorMsg))
|
||||
}
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR("No authorization code provided"))
|
||||
return
|
||||
}
|
||||
|
||||
// Validate state parameter
|
||||
if (!pendingAuths.has(state)) {
|
||||
const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
|
||||
log.error("oauth callback with invalid state", { state, pendingStates: Array.from(pendingAuths.keys()) })
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
const pending = pendingAuths.get(state)!
|
||||
|
||||
clearTimeout(pending.timeout)
|
||||
pendingAuths.delete(state)
|
||||
pending.resolve(code)
|
||||
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(HTML_SUCCESS)
|
||||
}
|
||||
|
||||
export async function ensureRunning(): Promise<void> {
|
||||
if (server) return
|
||||
|
||||
@@ -66,75 +130,14 @@ export namespace McpOAuthCallback {
|
||||
return
|
||||
}
|
||||
|
||||
server = Bun.serve({
|
||||
port: OAUTH_CALLBACK_PORT,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
|
||||
if (url.pathname !== OAUTH_CALLBACK_PATH) {
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
|
||||
const code = url.searchParams.get("code")
|
||||
const state = url.searchParams.get("state")
|
||||
const error = url.searchParams.get("error")
|
||||
const errorDescription = url.searchParams.get("error_description")
|
||||
|
||||
log.info("received oauth callback", { hasCode: !!code, state, error })
|
||||
|
||||
// Enforce state parameter presence
|
||||
if (!state) {
|
||||
const errorMsg = "Missing required state parameter - potential CSRF attack"
|
||||
log.error("oauth callback missing state parameter", { url: url.toString() })
|
||||
return new Response(HTML_ERROR(errorMsg), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const errorMsg = errorDescription || error
|
||||
if (pendingAuths.has(state)) {
|
||||
const pending = pendingAuths.get(state)!
|
||||
clearTimeout(pending.timeout)
|
||||
pendingAuths.delete(state)
|
||||
pending.reject(new Error(errorMsg))
|
||||
}
|
||||
return new Response(HTML_ERROR(errorMsg), {
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
return new Response(HTML_ERROR("No authorization code provided"), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
// Validate state parameter
|
||||
if (!pendingAuths.has(state)) {
|
||||
const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
|
||||
log.error("oauth callback with invalid state", { state, pendingStates: Array.from(pendingAuths.keys()) })
|
||||
return new Response(HTML_ERROR(errorMsg), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
const pending = pendingAuths.get(state)!
|
||||
|
||||
clearTimeout(pending.timeout)
|
||||
pendingAuths.delete(state)
|
||||
pending.resolve(code)
|
||||
|
||||
return new Response(HTML_SUCCESS, {
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
},
|
||||
server = createServer(handleRequest)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server!.listen(OAUTH_CALLBACK_PORT, () => {
|
||||
log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
|
||||
resolve()
|
||||
})
|
||||
server!.on("error", reject)
|
||||
})
|
||||
|
||||
log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
|
||||
}
|
||||
|
||||
export function waitForCallback(oauthState: string): Promise<string> {
|
||||
@@ -174,7 +177,7 @@ export namespace McpOAuthCallback {
|
||||
|
||||
export async function stop(): Promise<void> {
|
||||
if (server) {
|
||||
server.stop()
|
||||
await new Promise<void>((resolve) => server!.close(() => resolve()))
|
||||
server = undefined
|
||||
log.info("oauth callback server stopped")
|
||||
}
|
||||
|
||||
8
packages/opencode/src/node.ts
Normal file
8
packages/opencode/src/node.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Server } from "./server/server"
|
||||
|
||||
const result = await Server.listen({
|
||||
port: 1338,
|
||||
hostname: "0.0.0.0",
|
||||
})
|
||||
|
||||
console.log(result)
|
||||
160
packages/opencode/src/npm/index.ts
Normal file
160
packages/opencode/src/npm/index.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
// Workaround: Bun on Windows does not support the UV_FS_O_FILEMAP flag that
|
||||
// the `tar` package uses for files < 512KB (fs.open returns EINVAL).
|
||||
// tar silently swallows the error and skips writing files, leaving only empty
|
||||
// directories. Setting __FAKE_PLATFORM__ makes tar fall back to the plain 'w'
|
||||
// flag. See tar's get-write-flag.js.
|
||||
// Must be set before @npmcli/arborist is imported since tar caches the flag
|
||||
// at module evaluation time — so we use a dynamic import() below.
|
||||
if (process.platform === "win32") {
|
||||
process.env.__FAKE_PLATFORM__ = "linux"
|
||||
}
|
||||
|
||||
import semver from "semver"
|
||||
import z from "zod"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { Global } from "../global"
|
||||
import { Lock } from "../util/lock"
|
||||
import { Log } from "../util/log"
|
||||
import path from "path"
|
||||
import { readdir } from "fs/promises"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
|
||||
const { Arborist } = await import("@npmcli/arborist")
|
||||
|
||||
export namespace Npm {
|
||||
const log = Log.create({ service: "npm" })
|
||||
|
||||
export const InstallFailedError = NamedError.create(
|
||||
"NpmInstallFailedError",
|
||||
z.object({
|
||||
pkg: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
function directory(pkg: string) {
|
||||
return path.join(Global.Path.cache, "packages", pkg)
|
||||
}
|
||||
|
||||
export async function outdated(pkg: string, cachedVersion: string): Promise<boolean> {
|
||||
const response = await fetch(`https://registry.npmjs.org/${pkg}`)
|
||||
if (!response.ok) {
|
||||
log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
|
||||
return false
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { "dist-tags"?: { latest?: string } }
|
||||
const latestVersion = data?.["dist-tags"]?.latest
|
||||
if (!latestVersion) {
|
||||
log.warn("No latest version found, using cached", { pkg, cachedVersion })
|
||||
return false
|
||||
}
|
||||
|
||||
const isRange = /[\s^~*xX<>|=]/.test(cachedVersion)
|
||||
if (isRange) return !semver.satisfies(latestVersion, cachedVersion)
|
||||
|
||||
return semver.lt(cachedVersion, latestVersion)
|
||||
}
|
||||
|
||||
export async function add(pkg: string) {
|
||||
using _ = await Lock.write("npm-install")
|
||||
log.info("installing package", {
|
||||
pkg,
|
||||
})
|
||||
const hash = pkg
|
||||
const dir = directory(hash)
|
||||
|
||||
const arborist = new Arborist({
|
||||
path: dir,
|
||||
binLinks: true,
|
||||
progress: false,
|
||||
savePrefix: "",
|
||||
})
|
||||
const tree = await arborist.loadVirtual().catch(() => {})
|
||||
if (tree) {
|
||||
const first = tree.edgesOut.values().next().value?.to
|
||||
if (first) {
|
||||
log.info("package already installed", { pkg })
|
||||
return first.path
|
||||
}
|
||||
}
|
||||
|
||||
const result = await arborist
|
||||
.reify({
|
||||
add: [pkg],
|
||||
save: true,
|
||||
saveType: "prod",
|
||||
})
|
||||
.catch((cause) => {
|
||||
throw new InstallFailedError(
|
||||
{ pkg },
|
||||
{
|
||||
cause,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
const first = result.edgesOut.values().next().value?.to
|
||||
if (!first) throw new InstallFailedError({ pkg })
|
||||
return first.path
|
||||
}
|
||||
|
||||
export async function install(dir: string) {
|
||||
log.info("checking dependencies", { dir })
|
||||
|
||||
const reify = async () => {
|
||||
const arb = new Arborist({
|
||||
path: dir,
|
||||
binLinks: true,
|
||||
progress: false,
|
||||
savePrefix: "",
|
||||
})
|
||||
await arb.reify().catch(() => {})
|
||||
}
|
||||
|
||||
if (!(await Filesystem.exists(path.join(dir, "node_modules")))) {
|
||||
log.info("node_modules missing, reifying")
|
||||
await reify()
|
||||
return
|
||||
}
|
||||
|
||||
const pkg = await Filesystem.readJson(path.join(dir, "package.json")).catch(() => ({}))
|
||||
const lock = await Filesystem.readJson(path.join(dir, "package-lock.json")).catch(() => ({}))
|
||||
|
||||
const declared = new Set([
|
||||
...Object.keys(pkg.dependencies || {}),
|
||||
...Object.keys(pkg.devDependencies || {}),
|
||||
...Object.keys(pkg.peerDependencies || {}),
|
||||
...Object.keys(pkg.optionalDependencies || {}),
|
||||
])
|
||||
|
||||
const root = lock.packages?.[""] || {}
|
||||
const locked = new Set([
|
||||
...Object.keys(root.dependencies || {}),
|
||||
...Object.keys(root.devDependencies || {}),
|
||||
...Object.keys(root.peerDependencies || {}),
|
||||
...Object.keys(root.optionalDependencies || {}),
|
||||
])
|
||||
|
||||
for (const name of declared) {
|
||||
if (!locked.has(name)) {
|
||||
log.info("dependency not in lock file, reifying", { name })
|
||||
await reify()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
log.info("dependencies in sync")
|
||||
}
|
||||
|
||||
export async function which(pkg: string) {
|
||||
const dir = path.join(directory(pkg), "node_modules", ".bin")
|
||||
const files = await readdir(dir).catch(() => [])
|
||||
if (!files.length) {
|
||||
await add(pkg)
|
||||
const retry = await readdir(dir).catch(() => [])
|
||||
if (!retry.length) throw new Error(`No binary found for package "${pkg}" after install`)
|
||||
return path.join(dir, retry[0])
|
||||
}
|
||||
return path.join(dir, files[0])
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,251 @@
|
||||
import { runPromiseInstance } from "@/effect/runtime"
|
||||
import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Config } from "@/config/config"
|
||||
import { InstanceContext } from "@/effect/instance-context"
|
||||
import { ProjectID } from "@/project/schema"
|
||||
import { MessageID, SessionID } from "@/session/schema"
|
||||
import { PermissionTable } from "@/session/session.sql"
|
||||
import { Database, eq } from "@/storage/db"
|
||||
import { fn } from "@/util/fn"
|
||||
import { Log } from "@/util/log"
|
||||
import { Wildcard } from "@/util/wildcard"
|
||||
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
|
||||
import os from "os"
|
||||
import { PermissionEffect as S } from "./service"
|
||||
import z from "zod"
|
||||
import { PermissionID } from "./schema"
|
||||
|
||||
export namespace PermissionNext {
|
||||
const log = Log.create({ service: "permission" })
|
||||
|
||||
export const Action = z.enum(["allow", "deny", "ask"]).meta({
|
||||
ref: "PermissionAction",
|
||||
})
|
||||
export type Action = z.infer<typeof Action>
|
||||
|
||||
export const Rule = z
|
||||
.object({
|
||||
permission: z.string(),
|
||||
pattern: z.string(),
|
||||
action: Action,
|
||||
})
|
||||
.meta({
|
||||
ref: "PermissionRule",
|
||||
})
|
||||
export type Rule = z.infer<typeof Rule>
|
||||
|
||||
export const Ruleset = Rule.array().meta({
|
||||
ref: "PermissionRuleset",
|
||||
})
|
||||
export type Ruleset = z.infer<typeof Ruleset>
|
||||
|
||||
export const Request = z
|
||||
.object({
|
||||
id: PermissionID.zod,
|
||||
sessionID: SessionID.zod,
|
||||
permission: z.string(),
|
||||
patterns: z.string().array(),
|
||||
metadata: z.record(z.string(), z.any()),
|
||||
always: z.string().array(),
|
||||
tool: z
|
||||
.object({
|
||||
messageID: MessageID.zod,
|
||||
callID: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.meta({
|
||||
ref: "PermissionRequest",
|
||||
})
|
||||
export type Request = z.infer<typeof Request>
|
||||
|
||||
export const Reply = z.enum(["once", "always", "reject"])
|
||||
export type Reply = z.infer<typeof Reply>
|
||||
|
||||
export const Approval = z.object({
|
||||
projectID: ProjectID.zod,
|
||||
patterns: z.string().array(),
|
||||
})
|
||||
|
||||
export const Event = {
|
||||
Asked: BusEvent.define("permission.asked", Request),
|
||||
Replied: BusEvent.define(
|
||||
"permission.replied",
|
||||
z.object({
|
||||
sessionID: SessionID.zod,
|
||||
requestID: PermissionID.zod,
|
||||
reply: Reply,
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
|
||||
override get message() {
|
||||
return "The user rejected permission to use this specific tool call."
|
||||
}
|
||||
}
|
||||
|
||||
export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", {
|
||||
feedback: Schema.String,
|
||||
}) {
|
||||
override get message() {
|
||||
return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
|
||||
}
|
||||
}
|
||||
|
||||
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", {
|
||||
ruleset: Schema.Any,
|
||||
}) {
|
||||
override get message() {
|
||||
return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}`
|
||||
}
|
||||
}
|
||||
|
||||
export type Error = DeniedError | RejectedError | CorrectedError
|
||||
|
||||
export const AskInput = Request.partial({ id: true }).extend({
|
||||
ruleset: Ruleset,
|
||||
})
|
||||
|
||||
export const ReplyInput = z.object({
|
||||
requestID: PermissionID.zod,
|
||||
reply: Reply,
|
||||
message: z.string().optional(),
|
||||
})
|
||||
|
||||
export interface Interface {
|
||||
readonly ask: (input: z.infer<typeof AskInput>) => Effect.Effect<void, Error>
|
||||
readonly reply: (input: z.infer<typeof ReplyInput>) => Effect.Effect<void>
|
||||
readonly list: () => Effect.Effect<Request[]>
|
||||
}
|
||||
|
||||
interface PendingEntry {
|
||||
info: Request
|
||||
deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
|
||||
}
|
||||
|
||||
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
|
||||
const rules = rulesets.flat()
|
||||
log.info("evaluate", { permission, pattern, ruleset: rules })
|
||||
const match = rules.findLast(
|
||||
(rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
|
||||
)
|
||||
return match ?? { action: "ask", permission, pattern: "*" }
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/PermissionNext") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const { project } = yield* InstanceContext
|
||||
const row = Database.use((db) =>
|
||||
db.select().from(PermissionTable).where(eq(PermissionTable.project_id, project.id)).get(),
|
||||
)
|
||||
const pending = new Map<PermissionID, PendingEntry>()
|
||||
const approved: Ruleset = row?.data ?? []
|
||||
|
||||
const ask = Effect.fn("Permission.ask")(function* (input: z.infer<typeof AskInput>) {
|
||||
const { ruleset, ...request } = input
|
||||
let needsAsk = false
|
||||
|
||||
for (const pattern of request.patterns) {
|
||||
const rule = evaluate(request.permission, pattern, ruleset, approved)
|
||||
log.info("evaluated", { permission: request.permission, pattern, action: rule })
|
||||
if (rule.action === "deny") {
|
||||
return yield* new DeniedError({
|
||||
ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)),
|
||||
})
|
||||
}
|
||||
if (rule.action === "allow") continue
|
||||
needsAsk = true
|
||||
}
|
||||
|
||||
if (!needsAsk) return
|
||||
|
||||
const id = request.id ?? PermissionID.ascending()
|
||||
const info: Request = {
|
||||
id,
|
||||
...request,
|
||||
}
|
||||
log.info("asking", { id, permission: info.permission, patterns: info.patterns })
|
||||
|
||||
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
|
||||
pending.set(id, { info, deferred })
|
||||
void Bus.publish(Event.Asked, info)
|
||||
return yield* Effect.ensuring(
|
||||
Deferred.await(deferred),
|
||||
Effect.sync(() => {
|
||||
pending.delete(id)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const reply = Effect.fn("Permission.reply")(function* (input: z.infer<typeof ReplyInput>) {
|
||||
const existing = pending.get(input.requestID)
|
||||
if (!existing) return
|
||||
|
||||
pending.delete(input.requestID)
|
||||
void Bus.publish(Event.Replied, {
|
||||
sessionID: existing.info.sessionID,
|
||||
requestID: existing.info.id,
|
||||
reply: input.reply,
|
||||
})
|
||||
|
||||
if (input.reply === "reject") {
|
||||
yield* Deferred.fail(
|
||||
existing.deferred,
|
||||
input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
|
||||
)
|
||||
|
||||
for (const [id, item] of pending.entries()) {
|
||||
if (item.info.sessionID !== existing.info.sessionID) continue
|
||||
pending.delete(id)
|
||||
void Bus.publish(Event.Replied, {
|
||||
sessionID: item.info.sessionID,
|
||||
requestID: item.info.id,
|
||||
reply: "reject",
|
||||
})
|
||||
yield* Deferred.fail(item.deferred, new RejectedError())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
yield* Deferred.succeed(existing.deferred, undefined)
|
||||
if (input.reply === "once") return
|
||||
|
||||
for (const pattern of existing.info.always) {
|
||||
approved.push({
|
||||
permission: existing.info.permission,
|
||||
pattern,
|
||||
action: "allow",
|
||||
})
|
||||
}
|
||||
|
||||
for (const [id, item] of pending.entries()) {
|
||||
if (item.info.sessionID !== existing.info.sessionID) continue
|
||||
const ok = item.info.patterns.every(
|
||||
(pattern) => evaluate(item.info.permission, pattern, approved).action === "allow",
|
||||
)
|
||||
if (!ok) continue
|
||||
pending.delete(id)
|
||||
void Bus.publish(Event.Replied, {
|
||||
sessionID: item.info.sessionID,
|
||||
requestID: item.info.id,
|
||||
reply: "always",
|
||||
})
|
||||
yield* Deferred.succeed(item.deferred, undefined)
|
||||
}
|
||||
})
|
||||
|
||||
const list = Effect.fn("Permission.list")(function* () {
|
||||
return Array.from(pending.values(), (item) => item.info)
|
||||
})
|
||||
|
||||
return Service.of({ ask, reply, list })
|
||||
}),
|
||||
)
|
||||
|
||||
function expand(pattern: string): string {
|
||||
if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1)
|
||||
if (pattern === "~") return os.homedir()
|
||||
@@ -14,32 +254,11 @@ export namespace PermissionNext {
|
||||
return pattern
|
||||
}
|
||||
|
||||
export const Action = S.Action
|
||||
export type Action = S.Action
|
||||
export const Rule = S.Rule
|
||||
export type Rule = S.Rule
|
||||
export const Ruleset = S.Ruleset
|
||||
export type Ruleset = S.Ruleset
|
||||
export const Request = S.Request
|
||||
export type Request = S.Request
|
||||
export const Reply = S.Reply
|
||||
export type Reply = S.Reply
|
||||
export const Approval = S.Approval
|
||||
export const Event = S.Event
|
||||
export const Service = S.Service
|
||||
export const RejectedError = S.RejectedError
|
||||
export const CorrectedError = S.CorrectedError
|
||||
export const DeniedError = S.DeniedError
|
||||
|
||||
export function fromConfig(permission: Config.Permission) {
|
||||
const ruleset: Ruleset = []
|
||||
for (const [key, value] of Object.entries(permission)) {
|
||||
if (typeof value === "string") {
|
||||
ruleset.push({
|
||||
permission: key,
|
||||
action: value,
|
||||
pattern: "*",
|
||||
})
|
||||
ruleset.push({ permission: key, action: value, pattern: "*" })
|
||||
continue
|
||||
}
|
||||
ruleset.push(
|
||||
@@ -53,18 +272,12 @@ export namespace PermissionNext {
|
||||
return rulesets.flat()
|
||||
}
|
||||
|
||||
export const ask = fn(S.AskInput, async (input) => runPromiseInstance(S.Service.use((service) => service.ask(input))))
|
||||
export const ask = fn(AskInput, async (input) => runPromiseInstance(Service.use((svc) => svc.ask(input))))
|
||||
|
||||
export const reply = fn(S.ReplyInput, async (input) =>
|
||||
runPromiseInstance(S.Service.use((service) => service.reply(input))),
|
||||
)
|
||||
export const reply = fn(ReplyInput, async (input) => runPromiseInstance(Service.use((svc) => svc.reply(input))))
|
||||
|
||||
export async function list() {
|
||||
return runPromiseInstance(S.Service.use((service) => service.list()))
|
||||
}
|
||||
|
||||
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
|
||||
return S.evaluate(permission, pattern, ...rulesets)
|
||||
return runPromiseInstance(Service.use((svc) => svc.list()))
|
||||
}
|
||||
|
||||
const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"]
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceContext } from "@/effect/instance-context"
|
||||
import { ProjectID } from "@/project/schema"
|
||||
import { MessageID, SessionID } from "@/session/schema"
|
||||
import { PermissionTable } from "@/session/session.sql"
|
||||
import { Database, eq } from "@/storage/db"
|
||||
import { Log } from "@/util/log"
|
||||
import { Wildcard } from "@/util/wildcard"
|
||||
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
|
||||
import z from "zod"
|
||||
import { PermissionID } from "./schema"
|
||||
|
||||
export namespace PermissionEffect {
|
||||
const log = Log.create({ service: "permission" })
|
||||
|
||||
export const Action = z.enum(["allow", "deny", "ask"]).meta({
|
||||
ref: "PermissionAction",
|
||||
})
|
||||
export type Action = z.infer<typeof Action>
|
||||
|
||||
export const Rule = z
|
||||
.object({
|
||||
permission: z.string(),
|
||||
pattern: z.string(),
|
||||
action: Action,
|
||||
})
|
||||
.meta({
|
||||
ref: "PermissionRule",
|
||||
})
|
||||
export type Rule = z.infer<typeof Rule>
|
||||
|
||||
export const Ruleset = Rule.array().meta({
|
||||
ref: "PermissionRuleset",
|
||||
})
|
||||
export type Ruleset = z.infer<typeof Ruleset>
|
||||
|
||||
export const Request = z
|
||||
.object({
|
||||
id: PermissionID.zod,
|
||||
sessionID: SessionID.zod,
|
||||
permission: z.string(),
|
||||
patterns: z.string().array(),
|
||||
metadata: z.record(z.string(), z.any()),
|
||||
always: z.string().array(),
|
||||
tool: z
|
||||
.object({
|
||||
messageID: MessageID.zod,
|
||||
callID: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.meta({
|
||||
ref: "PermissionRequest",
|
||||
})
|
||||
export type Request = z.infer<typeof Request>
|
||||
|
||||
export const Reply = z.enum(["once", "always", "reject"])
|
||||
export type Reply = z.infer<typeof Reply>
|
||||
|
||||
export const Approval = z.object({
|
||||
projectID: ProjectID.zod,
|
||||
patterns: z.string().array(),
|
||||
})
|
||||
|
||||
export const Event = {
|
||||
Asked: BusEvent.define("permission.asked", Request),
|
||||
Replied: BusEvent.define(
|
||||
"permission.replied",
|
||||
z.object({
|
||||
sessionID: SessionID.zod,
|
||||
requestID: PermissionID.zod,
|
||||
reply: Reply,
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
|
||||
override get message() {
|
||||
return "The user rejected permission to use this specific tool call."
|
||||
}
|
||||
}
|
||||
|
||||
export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", {
|
||||
feedback: Schema.String,
|
||||
}) {
|
||||
override get message() {
|
||||
return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
|
||||
}
|
||||
}
|
||||
|
||||
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", {
|
||||
ruleset: Schema.Any,
|
||||
}) {
|
||||
override get message() {
|
||||
return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}`
|
||||
}
|
||||
}
|
||||
|
||||
export type Error = DeniedError | RejectedError | CorrectedError
|
||||
|
||||
export const AskInput = Request.partial({ id: true }).extend({
|
||||
ruleset: Ruleset,
|
||||
})
|
||||
|
||||
export const ReplyInput = z.object({
|
||||
requestID: PermissionID.zod,
|
||||
reply: Reply,
|
||||
message: z.string().optional(),
|
||||
})
|
||||
|
||||
export interface Api {
|
||||
readonly ask: (input: z.infer<typeof AskInput>) => Effect.Effect<void, Error>
|
||||
readonly reply: (input: z.infer<typeof ReplyInput>) => Effect.Effect<void>
|
||||
readonly list: () => Effect.Effect<Request[]>
|
||||
}
|
||||
|
||||
interface PendingEntry {
|
||||
info: Request
|
||||
deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
|
||||
}
|
||||
|
||||
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
|
||||
const rules = rulesets.flat()
|
||||
log.info("evaluate", { permission, pattern, ruleset: rules })
|
||||
const match = rules.findLast(
|
||||
(rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
|
||||
)
|
||||
return match ?? { action: "ask", permission, pattern: "*" }
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Api>()("@opencode/PermissionNext") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const { project } = yield* InstanceContext
|
||||
const row = Database.use((db) =>
|
||||
db.select().from(PermissionTable).where(eq(PermissionTable.project_id, project.id)).get(),
|
||||
)
|
||||
const pending = new Map<PermissionID, PendingEntry>()
|
||||
const approved: Ruleset = row?.data ?? []
|
||||
|
||||
const ask = Effect.fn("PermissionService.ask")(function* (input: z.infer<typeof AskInput>) {
|
||||
const { ruleset, ...request } = input
|
||||
let needsAsk = false
|
||||
|
||||
for (const pattern of request.patterns) {
|
||||
const rule = evaluate(request.permission, pattern, ruleset, approved)
|
||||
log.info("evaluated", { permission: request.permission, pattern, action: rule })
|
||||
if (rule.action === "deny") {
|
||||
return yield* new DeniedError({
|
||||
ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)),
|
||||
})
|
||||
}
|
||||
if (rule.action === "allow") continue
|
||||
needsAsk = true
|
||||
}
|
||||
|
||||
if (!needsAsk) return
|
||||
|
||||
const id = request.id ?? PermissionID.ascending()
|
||||
const info: Request = {
|
||||
id,
|
||||
...request,
|
||||
}
|
||||
log.info("asking", { id, permission: info.permission, patterns: info.patterns })
|
||||
|
||||
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
|
||||
pending.set(id, { info, deferred })
|
||||
void Bus.publish(Event.Asked, info)
|
||||
return yield* Effect.ensuring(
|
||||
Deferred.await(deferred),
|
||||
Effect.sync(() => {
|
||||
pending.delete(id)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const reply = Effect.fn("PermissionService.reply")(function* (input: z.infer<typeof ReplyInput>) {
|
||||
const existing = pending.get(input.requestID)
|
||||
if (!existing) return
|
||||
|
||||
pending.delete(input.requestID)
|
||||
void Bus.publish(Event.Replied, {
|
||||
sessionID: existing.info.sessionID,
|
||||
requestID: existing.info.id,
|
||||
reply: input.reply,
|
||||
})
|
||||
|
||||
if (input.reply === "reject") {
|
||||
yield* Deferred.fail(
|
||||
existing.deferred,
|
||||
input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
|
||||
)
|
||||
|
||||
for (const [id, item] of pending.entries()) {
|
||||
if (item.info.sessionID !== existing.info.sessionID) continue
|
||||
pending.delete(id)
|
||||
void Bus.publish(Event.Replied, {
|
||||
sessionID: item.info.sessionID,
|
||||
requestID: item.info.id,
|
||||
reply: "reject",
|
||||
})
|
||||
yield* Deferred.fail(item.deferred, new RejectedError())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
yield* Deferred.succeed(existing.deferred, undefined)
|
||||
if (input.reply === "once") return
|
||||
|
||||
for (const pattern of existing.info.always) {
|
||||
approved.push({
|
||||
permission: existing.info.permission,
|
||||
pattern,
|
||||
action: "allow",
|
||||
})
|
||||
}
|
||||
|
||||
for (const [id, item] of pending.entries()) {
|
||||
if (item.info.sessionID !== existing.info.sessionID) continue
|
||||
const ok = item.info.patterns.every(
|
||||
(pattern) => evaluate(item.info.permission, pattern, approved).action === "allow",
|
||||
)
|
||||
if (!ok) continue
|
||||
pending.delete(id)
|
||||
void Bus.publish(Event.Replied, {
|
||||
sessionID: item.info.sessionID,
|
||||
requestID: item.info.id,
|
||||
reply: "always",
|
||||
})
|
||||
yield* Deferred.succeed(item.deferred, undefined)
|
||||
}
|
||||
})
|
||||
|
||||
const list = Effect.fn("PermissionService.list")(function* () {
|
||||
return Array.from(pending.values(), (item) => item.info)
|
||||
})
|
||||
|
||||
return Service.of({ ask, reply, list })
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import os from "os"
|
||||
import { ProviderTransform } from "@/provider/transform"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { createServer } from "http"
|
||||
|
||||
const log = Log.create({ service: "plugin.codex" })
|
||||
|
||||
@@ -241,7 +242,7 @@ interface PendingOAuth {
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
let oauthServer: ReturnType<typeof Bun.serve> | undefined
|
||||
let oauthServer: ReturnType<typeof createServer> | undefined
|
||||
let pendingOAuth: PendingOAuth | undefined
|
||||
|
||||
async function startOAuthServer(): Promise<{ port: number; redirectUri: string }> {
|
||||
@@ -249,77 +250,83 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
|
||||
return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` }
|
||||
}
|
||||
|
||||
oauthServer = Bun.serve({
|
||||
port: OAUTH_PORT,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
oauthServer = createServer((req, res) => {
|
||||
const url = new URL(req.url || "/", `http://localhost:${OAUTH_PORT}`)
|
||||
|
||||
if (url.pathname === "/auth/callback") {
|
||||
const code = url.searchParams.get("code")
|
||||
const state = url.searchParams.get("state")
|
||||
const error = url.searchParams.get("error")
|
||||
const errorDescription = url.searchParams.get("error_description")
|
||||
if (url.pathname === "/auth/callback") {
|
||||
const code = url.searchParams.get("code")
|
||||
const state = url.searchParams.get("state")
|
||||
const error = url.searchParams.get("error")
|
||||
const errorDescription = url.searchParams.get("error_description")
|
||||
|
||||
if (error) {
|
||||
const errorMsg = errorDescription || error
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
return new Response(HTML_ERROR(errorMsg), {
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
const errorMsg = "Missing authorization code"
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
return new Response(HTML_ERROR(errorMsg), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
if (!pendingOAuth || state !== pendingOAuth.state) {
|
||||
const errorMsg = "Invalid state - potential CSRF attack"
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
return new Response(HTML_ERROR(errorMsg), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
const current = pendingOAuth
|
||||
if (error) {
|
||||
const errorMsg = errorDescription || error
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
|
||||
exchangeCodeForTokens(code, `http://localhost:${OAUTH_PORT}/auth/callback`, current.pkce)
|
||||
.then((tokens) => current.resolve(tokens))
|
||||
.catch((err) => current.reject(err))
|
||||
|
||||
return new Response(HTML_SUCCESS, {
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
if (url.pathname === "/cancel") {
|
||||
pendingOAuth?.reject(new Error("Login cancelled"))
|
||||
if (!code) {
|
||||
const errorMsg = "Missing authorization code"
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
return new Response("Login cancelled", { status: 200 })
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
return new Response("Not found", { status: 404 })
|
||||
},
|
||||
if (!pendingOAuth || state !== pendingOAuth.state) {
|
||||
const errorMsg = "Invalid state - potential CSRF attack"
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
const current = pendingOAuth
|
||||
pendingOAuth = undefined
|
||||
|
||||
exchangeCodeForTokens(code, `http://localhost:${OAUTH_PORT}/auth/callback`, current.pkce)
|
||||
.then((tokens) => current.resolve(tokens))
|
||||
.catch((err) => current.reject(err))
|
||||
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(HTML_SUCCESS)
|
||||
return
|
||||
}
|
||||
|
||||
if (url.pathname === "/cancel") {
|
||||
pendingOAuth?.reject(new Error("Login cancelled"))
|
||||
pendingOAuth = undefined
|
||||
res.writeHead(200)
|
||||
res.end("Login cancelled")
|
||||
return
|
||||
}
|
||||
|
||||
res.writeHead(404)
|
||||
res.end("Not found")
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
oauthServer!.listen(OAUTH_PORT, () => {
|
||||
log.info("codex oauth server started", { port: OAUTH_PORT })
|
||||
resolve()
|
||||
})
|
||||
oauthServer!.on("error", reject)
|
||||
})
|
||||
|
||||
log.info("codex oauth server started", { port: OAUTH_PORT })
|
||||
return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` }
|
||||
}
|
||||
|
||||
function stopOAuthServer() {
|
||||
if (oauthServer) {
|
||||
oauthServer.stop()
|
||||
oauthServer.close(() => {
|
||||
log.info("codex oauth server stopped")
|
||||
})
|
||||
oauthServer = undefined
|
||||
log.info("codex oauth server stopped")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Bus } from "../bus"
|
||||
import { Log } from "../util/log"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
import { Server } from "../server/server"
|
||||
import { BunProc } from "../bun"
|
||||
import { Npm } from "../npm"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { CodexAuthPlugin } from "./codex"
|
||||
@@ -32,7 +32,9 @@ export namespace Plugin {
|
||||
: undefined,
|
||||
fetch: async (...args) => Server.Default().fetch(...args),
|
||||
})
|
||||
log.info("loading config")
|
||||
const config = await Config.get()
|
||||
log.info("config loaded")
|
||||
const hooks: Hooks[] = []
|
||||
const input: PluginInput = {
|
||||
client,
|
||||
@@ -42,7 +44,8 @@ export namespace Plugin {
|
||||
get serverUrl(): URL {
|
||||
return Server.url ?? new URL("http://localhost:4096")
|
||||
},
|
||||
$: Bun.$,
|
||||
// @ts-expect-error
|
||||
$: typeof Bun === "undefined" ? undefined : Bun.$,
|
||||
}
|
||||
|
||||
for (const plugin of INTERNAL_PLUGINS) {
|
||||
@@ -64,16 +67,13 @@ export namespace Plugin {
|
||||
if (plugin.includes("opencode-openai-codex-auth") || plugin.includes("opencode-copilot-auth")) continue
|
||||
log.info("loading plugin", { path: plugin })
|
||||
if (!plugin.startsWith("file://")) {
|
||||
const lastAtIndex = plugin.lastIndexOf("@")
|
||||
const pkg = lastAtIndex > 0 ? plugin.substring(0, lastAtIndex) : plugin
|
||||
const version = lastAtIndex > 0 ? plugin.substring(lastAtIndex + 1) : "latest"
|
||||
plugin = await BunProc.install(pkg, version).catch((err) => {
|
||||
plugin = await Npm.add(plugin).catch((err) => {
|
||||
const cause = err instanceof Error ? err.cause : err
|
||||
const detail = cause instanceof Error ? cause.message : String(cause ?? err)
|
||||
log.error("failed to install plugin", { pkg, version, error: detail })
|
||||
log.error("failed to install plugin", { plugin, error: detail })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: new NamedError.Unknown({
|
||||
message: `Failed to install plugin ${pkg}@${version}: ${detail}`,
|
||||
message: `Failed to install plugin ${plugin}: ${detail}`,
|
||||
}).toObject(),
|
||||
})
|
||||
return ""
|
||||
|
||||
@@ -1,30 +1,23 @@
|
||||
import { Plugin } from "../plugin"
|
||||
import { Format } from "../format"
|
||||
import { LSP } from "../lsp"
|
||||
import { FileWatcherService } from "../file/watcher"
|
||||
import { File } from "../file"
|
||||
import { Project } from "./project"
|
||||
import { Bus } from "../bus"
|
||||
import { Command } from "../command"
|
||||
import { Instance } from "./instance"
|
||||
import { VcsService } from "./vcs"
|
||||
import { Log } from "@/util/log"
|
||||
import { ShareNext } from "@/share/share-next"
|
||||
import { runPromiseInstance } from "@/effect/runtime"
|
||||
|
||||
export async function InstanceBootstrap() {
|
||||
Log.Default.info("bootstrapping", { directory: Instance.directory })
|
||||
await Plugin.init()
|
||||
ShareNext.init()
|
||||
await Format.init()
|
||||
await LSP.init()
|
||||
await runPromiseInstance(FileWatcherService.use((service) => service.init()))
|
||||
File.init()
|
||||
await runPromiseInstance(VcsService.use((s) => s.init()))
|
||||
|
||||
Bus.subscribe(Command.Event.Executed, async (payload) => {
|
||||
if (payload.properties.name === Command.Default.INIT) {
|
||||
await Project.setInitialized(Instance.project.id)
|
||||
Project.setInitialized(Instance.project.id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { Bus } from "@/bus"
|
||||
import z from "zod"
|
||||
import { Log } from "@/util/log"
|
||||
import { Instance } from "./instance"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceContext } from "@/effect/instance-context"
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
import { Log } from "@/util/log"
|
||||
import { git } from "@/util/git"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
|
||||
const log = Log.create({ service: "vcs" })
|
||||
import { Instance } from "./instance"
|
||||
import z from "zod"
|
||||
|
||||
export namespace Vcs {
|
||||
const log = Log.create({ service: "vcs" })
|
||||
|
||||
export const Event = {
|
||||
BranchUpdated: BusEvent.define(
|
||||
"vcs.branch.updated",
|
||||
@@ -28,24 +28,21 @@ export namespace Vcs {
|
||||
ref: "VcsInfo",
|
||||
})
|
||||
export type Info = z.infer<typeof Info>
|
||||
}
|
||||
|
||||
export namespace VcsService {
|
||||
export interface Service {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
export interface Interface {
|
||||
readonly branch: () => Effect.Effect<string | undefined>
|
||||
}
|
||||
}
|
||||
|
||||
export class VcsService extends ServiceMap.Service<VcsService, VcsService.Service>()("@opencode/Vcs") {
|
||||
static readonly layer = Layer.effect(
|
||||
VcsService,
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Vcs") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* InstanceContext
|
||||
let current: string | undefined
|
||||
let currentBranch: string | undefined
|
||||
|
||||
if (instance.project.vcs === "git") {
|
||||
const currentBranch = async () => {
|
||||
const getCurrentBranch = async () => {
|
||||
const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], {
|
||||
cwd: instance.project.worktree,
|
||||
})
|
||||
@@ -54,29 +51,31 @@ export class VcsService extends ServiceMap.Service<VcsService, VcsService.Servic
|
||||
return text || undefined
|
||||
}
|
||||
|
||||
current = yield* Effect.promise(() => currentBranch())
|
||||
log.info("initialized", { branch: current })
|
||||
currentBranch = yield* Effect.promise(() => getCurrentBranch())
|
||||
log.info("initialized", { branch: currentBranch })
|
||||
|
||||
const unsubscribe = Bus.subscribe(
|
||||
FileWatcher.Event.Updated,
|
||||
Instance.bind(async (evt) => {
|
||||
if (!evt.properties.file.endsWith("HEAD")) return
|
||||
const next = await currentBranch()
|
||||
if (next !== current) {
|
||||
log.info("branch changed", { from: current, to: next })
|
||||
current = next
|
||||
Bus.publish(Vcs.Event.BranchUpdated, { branch: next })
|
||||
}
|
||||
}),
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
Bus.subscribe(
|
||||
FileWatcher.Event.Updated,
|
||||
Instance.bind(async (evt) => {
|
||||
if (!evt.properties.file.endsWith("HEAD")) return
|
||||
const next = await getCurrentBranch()
|
||||
if (next !== currentBranch) {
|
||||
log.info("branch changed", { from: currentBranch, to: next })
|
||||
currentBranch = next
|
||||
Bus.publish(Event.BranchUpdated, { branch: next })
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
(unsubscribe) => Effect.sync(unsubscribe),
|
||||
)
|
||||
|
||||
yield* Effect.addFinalizer(() => Effect.sync(unsubscribe))
|
||||
}
|
||||
|
||||
return VcsService.of({
|
||||
init: Effect.fn("VcsService.init")(function* () {}),
|
||||
branch: Effect.fn("VcsService.branch")(function* () {
|
||||
return current
|
||||
return Service.of({
|
||||
branch: Effect.fn("Vcs.branch")(function* () {
|
||||
return currentBranch
|
||||
}),
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
import type { AuthOuathResult } from "@opencode-ai/plugin"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import * as Auth from "@/auth/service"
|
||||
import { ProviderID } from "./schema"
|
||||
import { Effect, Layer, Record, ServiceMap, Struct } from "effect"
|
||||
import { filter, fromEntries, map, pipe } from "remeda"
|
||||
import z from "zod"
|
||||
|
||||
export const Method = z
|
||||
.object({
|
||||
type: z.union([z.literal("oauth"), z.literal("api")]),
|
||||
label: z.string(),
|
||||
prompts: z
|
||||
.array(
|
||||
z.union([
|
||||
z.object({
|
||||
type: z.literal("text"),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
placeholder: z.string().optional(),
|
||||
when: z
|
||||
.object({
|
||||
key: z.string(),
|
||||
op: z.union([z.literal("eq"), z.literal("neq")]),
|
||||
value: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("select"),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
options: z.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
hint: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
when: z
|
||||
.object({
|
||||
key: z.string(),
|
||||
op: z.union([z.literal("eq"), z.literal("neq")]),
|
||||
value: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
]),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.meta({
|
||||
ref: "ProviderAuthMethod",
|
||||
})
|
||||
export type Method = z.infer<typeof Method>
|
||||
|
||||
export const Authorization = z
|
||||
.object({
|
||||
url: z.string(),
|
||||
method: z.union([z.literal("auto"), z.literal("code")]),
|
||||
instructions: z.string(),
|
||||
})
|
||||
.meta({
|
||||
ref: "ProviderAuthAuthorization",
|
||||
})
|
||||
export type Authorization = z.infer<typeof Authorization>
|
||||
|
||||
export const OauthMissing = NamedError.create(
|
||||
"ProviderAuthOauthMissing",
|
||||
z.object({
|
||||
providerID: ProviderID.zod,
|
||||
}),
|
||||
)
|
||||
|
||||
export const OauthCodeMissing = NamedError.create(
|
||||
"ProviderAuthOauthCodeMissing",
|
||||
z.object({
|
||||
providerID: ProviderID.zod,
|
||||
}),
|
||||
)
|
||||
|
||||
export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({}))
|
||||
|
||||
export const ValidationFailed = NamedError.create(
|
||||
"ProviderAuthValidationFailed",
|
||||
z.object({
|
||||
field: z.string(),
|
||||
message: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
export type ProviderAuthError =
|
||||
| Auth.AuthServiceError
|
||||
| InstanceType<typeof OauthMissing>
|
||||
| InstanceType<typeof OauthCodeMissing>
|
||||
| InstanceType<typeof OauthCallbackFailed>
|
||||
| InstanceType<typeof ValidationFailed>
|
||||
|
||||
export namespace ProviderAuthService {
|
||||
export interface Service {
|
||||
readonly methods: () => Effect.Effect<Record<string, Method[]>>
|
||||
readonly authorize: (input: {
|
||||
providerID: ProviderID
|
||||
method: number
|
||||
inputs?: Record<string, string>
|
||||
}) => Effect.Effect<Authorization | undefined, ProviderAuthError>
|
||||
readonly callback: (input: {
|
||||
providerID: ProviderID
|
||||
method: number
|
||||
code?: string
|
||||
}) => Effect.Effect<void, ProviderAuthError>
|
||||
}
|
||||
}
|
||||
|
||||
export class ProviderAuthService extends ServiceMap.Service<ProviderAuthService, ProviderAuthService.Service>()(
|
||||
"@opencode/ProviderAuth",
|
||||
) {
|
||||
static readonly layer = Layer.effect(
|
||||
ProviderAuthService,
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.AuthService
|
||||
const hooks = yield* Effect.promise(async () => {
|
||||
const mod = await import("../plugin")
|
||||
return pipe(
|
||||
await mod.Plugin.list(),
|
||||
filter((x) => x.auth?.provider !== undefined),
|
||||
map((x) => [x.auth!.provider, x.auth!] as const),
|
||||
fromEntries(),
|
||||
)
|
||||
})
|
||||
const pending = new Map<ProviderID, AuthOuathResult>()
|
||||
|
||||
const methods = Effect.fn("ProviderAuthService.methods")(function* () {
|
||||
return Record.map(hooks, (item) =>
|
||||
item.methods.map(
|
||||
(method): Method => ({
|
||||
type: method.type,
|
||||
label: method.label,
|
||||
prompts: method.prompts?.map((prompt) => {
|
||||
if (prompt.type === "select") {
|
||||
return {
|
||||
type: "select" as const,
|
||||
key: prompt.key,
|
||||
message: prompt.message,
|
||||
options: prompt.options,
|
||||
when: prompt.when,
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: "text" as const,
|
||||
key: prompt.key,
|
||||
message: prompt.message,
|
||||
placeholder: prompt.placeholder,
|
||||
when: prompt.when,
|
||||
}
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const authorize = Effect.fn("ProviderAuthService.authorize")(function* (input: {
|
||||
providerID: ProviderID
|
||||
method: number
|
||||
inputs?: Record<string, string>
|
||||
}) {
|
||||
const method = hooks[input.providerID].methods[input.method]
|
||||
if (method.type !== "oauth") return
|
||||
|
||||
if (method.prompts && input.inputs) {
|
||||
for (const prompt of method.prompts) {
|
||||
if (prompt.type === "text" && prompt.validate && input.inputs[prompt.key] !== undefined) {
|
||||
const error = prompt.validate(input.inputs[prompt.key])
|
||||
if (error) return yield* Effect.fail(new ValidationFailed({ field: prompt.key, message: error }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = yield* Effect.promise(() => method.authorize(input.inputs))
|
||||
pending.set(input.providerID, result)
|
||||
return {
|
||||
url: result.url,
|
||||
method: result.method,
|
||||
instructions: result.instructions,
|
||||
}
|
||||
})
|
||||
|
||||
const callback = Effect.fn("ProviderAuthService.callback")(function* (input: {
|
||||
providerID: ProviderID
|
||||
method: number
|
||||
code?: string
|
||||
}) {
|
||||
const match = pending.get(input.providerID)
|
||||
if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID }))
|
||||
if (match.method === "code" && !input.code)
|
||||
return yield* Effect.fail(new OauthCodeMissing({ providerID: input.providerID }))
|
||||
|
||||
const result = yield* Effect.promise(() =>
|
||||
match.method === "code" ? match.callback(input.code!) : match.callback(),
|
||||
)
|
||||
if (!result || result.type !== "success") return yield* Effect.fail(new OauthCallbackFailed({}))
|
||||
|
||||
if ("key" in result) {
|
||||
yield* auth.set(input.providerID, {
|
||||
type: "api",
|
||||
key: result.key,
|
||||
})
|
||||
}
|
||||
|
||||
if ("refresh" in result) {
|
||||
yield* auth.set(input.providerID, {
|
||||
type: "oauth",
|
||||
access: result.access,
|
||||
refresh: result.refresh,
|
||||
expires: result.expires,
|
||||
...(result.accountId ? { accountId: result.accountId } : {}),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return ProviderAuthService.of({
|
||||
methods,
|
||||
authorize,
|
||||
callback,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
static readonly defaultLayer = ProviderAuthService.layer.pipe(Layer.provide(Auth.AuthService.defaultLayer))
|
||||
}
|
||||
@@ -1,20 +1,223 @@
|
||||
import z from "zod"
|
||||
|
||||
import type { AuthOuathResult } from "@opencode-ai/plugin"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import * as Auth from "@/auth/effect"
|
||||
import { runPromiseInstance } from "@/effect/runtime"
|
||||
import { fn } from "@/util/fn"
|
||||
import * as S from "./auth-service"
|
||||
import { ProviderID } from "./schema"
|
||||
import { Array as Arr, Effect, Layer, Record, Result, ServiceMap, Struct } from "effect"
|
||||
import z from "zod"
|
||||
|
||||
export namespace ProviderAuth {
|
||||
export const Method = S.Method
|
||||
export type Method = S.Method
|
||||
export const Method = z
|
||||
.object({
|
||||
type: z.union([z.literal("oauth"), z.literal("api")]),
|
||||
label: z.string(),
|
||||
prompts: z
|
||||
.array(
|
||||
z.union([
|
||||
z.object({
|
||||
type: z.literal("text"),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
placeholder: z.string().optional(),
|
||||
when: z
|
||||
.object({
|
||||
key: z.string(),
|
||||
op: z.union([z.literal("eq"), z.literal("neq")]),
|
||||
value: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("select"),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
options: z.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
hint: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
when: z
|
||||
.object({
|
||||
key: z.string(),
|
||||
op: z.union([z.literal("eq"), z.literal("neq")]),
|
||||
value: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
]),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.meta({
|
||||
ref: "ProviderAuthMethod",
|
||||
})
|
||||
export type Method = z.infer<typeof Method>
|
||||
|
||||
export async function methods() {
|
||||
return runPromiseInstance(S.ProviderAuthService.use((service) => service.methods()))
|
||||
export const Authorization = z
|
||||
.object({
|
||||
url: z.string(),
|
||||
method: z.union([z.literal("auto"), z.literal("code")]),
|
||||
instructions: z.string(),
|
||||
})
|
||||
.meta({
|
||||
ref: "ProviderAuthAuthorization",
|
||||
})
|
||||
export type Authorization = z.infer<typeof Authorization>
|
||||
|
||||
export const OauthMissing = NamedError.create("ProviderAuthOauthMissing", z.object({ providerID: ProviderID.zod }))
|
||||
|
||||
export const OauthCodeMissing = NamedError.create(
|
||||
"ProviderAuthOauthCodeMissing",
|
||||
z.object({ providerID: ProviderID.zod }),
|
||||
)
|
||||
|
||||
export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({}))
|
||||
|
||||
export const ValidationFailed = NamedError.create(
|
||||
"ProviderAuthValidationFailed",
|
||||
z.object({
|
||||
field: z.string(),
|
||||
message: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
export type Error =
|
||||
| Auth.AuthError
|
||||
| InstanceType<typeof OauthMissing>
|
||||
| InstanceType<typeof OauthCodeMissing>
|
||||
| InstanceType<typeof OauthCallbackFailed>
|
||||
| InstanceType<typeof ValidationFailed>
|
||||
|
||||
export interface Interface {
|
||||
readonly methods: () => Effect.Effect<Record<ProviderID, Method[]>>
|
||||
readonly authorize: (input: {
|
||||
providerID: ProviderID
|
||||
method: number
|
||||
inputs?: Record<string, string>
|
||||
}) => Effect.Effect<Authorization | undefined, Error>
|
||||
readonly callback: (input: { providerID: ProviderID; method: number; code?: string }) => Effect.Effect<void, Error>
|
||||
}
|
||||
|
||||
export const Authorization = S.Authorization
|
||||
export type Authorization = S.Authorization
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ProviderAuth") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.AuthEffect.Service
|
||||
const hooks = yield* Effect.promise(async () => {
|
||||
const mod = await import("../plugin")
|
||||
const plugins = await mod.Plugin.list()
|
||||
return Record.fromEntries(
|
||||
Arr.filterMap(plugins, (x) =>
|
||||
x.auth?.provider !== undefined
|
||||
? Result.succeed([ProviderID.make(x.auth.provider), x.auth] as const)
|
||||
: Result.failVoid,
|
||||
),
|
||||
)
|
||||
})
|
||||
const pending = new Map<ProviderID, AuthOuathResult>()
|
||||
|
||||
const methods = Effect.fn("ProviderAuth.methods")(function* () {
|
||||
return Record.map(hooks, (item) =>
|
||||
item.methods.map(
|
||||
(method): Method => ({
|
||||
type: method.type,
|
||||
label: method.label,
|
||||
prompts: method.prompts?.map((prompt) => {
|
||||
if (prompt.type === "select") {
|
||||
return {
|
||||
type: "select" as const,
|
||||
key: prompt.key,
|
||||
message: prompt.message,
|
||||
options: prompt.options,
|
||||
when: prompt.when,
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: "text" as const,
|
||||
key: prompt.key,
|
||||
message: prompt.message,
|
||||
placeholder: prompt.placeholder,
|
||||
when: prompt.when,
|
||||
}
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const authorize = Effect.fn("ProviderAuth.authorize")(function* (input: {
|
||||
providerID: ProviderID
|
||||
method: number
|
||||
inputs?: Record<string, string>
|
||||
}) {
|
||||
const method = hooks[input.providerID].methods[input.method]
|
||||
if (method.type !== "oauth") return
|
||||
|
||||
if (method.prompts && input.inputs) {
|
||||
for (const prompt of method.prompts) {
|
||||
if (prompt.type === "text" && prompt.validate && input.inputs[prompt.key] !== undefined) {
|
||||
const error = prompt.validate(input.inputs[prompt.key])
|
||||
if (error) return yield* Effect.fail(new ValidationFailed({ field: prompt.key, message: error }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = yield* Effect.promise(() => method.authorize(input.inputs))
|
||||
pending.set(input.providerID, result)
|
||||
return {
|
||||
url: result.url,
|
||||
method: result.method,
|
||||
instructions: result.instructions,
|
||||
}
|
||||
})
|
||||
|
||||
const callback = Effect.fn("ProviderAuth.callback")(function* (input: {
|
||||
providerID: ProviderID
|
||||
method: number
|
||||
code?: string
|
||||
}) {
|
||||
const match = pending.get(input.providerID)
|
||||
if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID }))
|
||||
if (match.method === "code" && !input.code) {
|
||||
return yield* Effect.fail(new OauthCodeMissing({ providerID: input.providerID }))
|
||||
}
|
||||
|
||||
const result = yield* Effect.promise(() =>
|
||||
match.method === "code" ? match.callback(input.code!) : match.callback(),
|
||||
)
|
||||
if (!result || result.type !== "success") return yield* Effect.fail(new OauthCallbackFailed({}))
|
||||
|
||||
if ("key" in result) {
|
||||
yield* auth.set(input.providerID, {
|
||||
type: "api",
|
||||
key: result.key,
|
||||
})
|
||||
}
|
||||
|
||||
if ("refresh" in result) {
|
||||
yield* auth.set(input.providerID, {
|
||||
type: "oauth",
|
||||
access: result.access,
|
||||
refresh: result.refresh,
|
||||
expires: result.expires,
|
||||
...(result.accountId ? { accountId: result.accountId } : {}),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({ methods, authorize, callback })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Auth.AuthEffect.layer))
|
||||
|
||||
export async function methods() {
|
||||
return runPromiseInstance(Service.use((svc) => svc.methods()))
|
||||
}
|
||||
|
||||
export const authorize = fn(
|
||||
z.object({
|
||||
@@ -22,8 +225,7 @@ export namespace ProviderAuth {
|
||||
method: z.number(),
|
||||
inputs: z.record(z.string(), z.string()).optional(),
|
||||
}),
|
||||
async (input): Promise<Authorization | undefined> =>
|
||||
runPromiseInstance(S.ProviderAuthService.use((service) => service.authorize(input))),
|
||||
async (input): Promise<Authorization | undefined> => runPromiseInstance(Service.use((svc) => svc.authorize(input))),
|
||||
)
|
||||
|
||||
export const callback = fn(
|
||||
@@ -32,11 +234,6 @@ export namespace ProviderAuth {
|
||||
method: z.number(),
|
||||
code: z.string().optional(),
|
||||
}),
|
||||
async (input) => runPromiseInstance(S.ProviderAuthService.use((service) => service.callback(input))),
|
||||
async (input) => runPromiseInstance(Service.use((svc) => svc.callback(input))),
|
||||
)
|
||||
|
||||
export import OauthMissing = S.OauthMissing
|
||||
export import OauthCodeMissing = S.OauthCodeMissing
|
||||
export import OauthCallbackFailed = S.OauthCallbackFailed
|
||||
export import ValidationFailed = S.ValidationFailed
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Config } from "../config/config"
|
||||
import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda"
|
||||
import { NoSuchModelError, type Provider as SDK } from "ai"
|
||||
import { Log } from "../util/log"
|
||||
import { BunProc } from "../bun"
|
||||
import { Npm } from "../npm"
|
||||
import { Hash } from "../util/hash"
|
||||
import { Plugin } from "../plugin"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
@@ -1190,7 +1190,7 @@ export namespace Provider {
|
||||
|
||||
let installedPath: string
|
||||
if (!model.api.npm.startsWith("file://")) {
|
||||
installedPath = await BunProc.install(model.api.npm, "latest")
|
||||
installedPath = await Npm.add(model.api.npm)
|
||||
} else {
|
||||
log.info("loading local provider", { pkg: model.api.npm })
|
||||
installedPath = model.api.npm
|
||||
|
||||
@@ -23,6 +23,8 @@ export namespace Pty {
|
||||
close: (code?: number, reason?: string) => void
|
||||
}
|
||||
|
||||
const key = (ws: Socket) => (ws.data && typeof ws.data === "object" ? ws.data : ws)
|
||||
|
||||
// WebSocket control frame: 0x00 + UTF-8 JSON.
|
||||
const meta = (cursor: number) => {
|
||||
const json = JSON.stringify({ cursor })
|
||||
@@ -97,9 +99,9 @@ export namespace Pty {
|
||||
try {
|
||||
session.process.kill()
|
||||
} catch {}
|
||||
for (const [key, ws] of session.subscribers.entries()) {
|
||||
for (const [id, ws] of session.subscribers.entries()) {
|
||||
try {
|
||||
if (ws.data === key) ws.close()
|
||||
if (key(ws) === id) ws.close()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -230,9 +232,9 @@ export namespace Pty {
|
||||
try {
|
||||
session.process.kill()
|
||||
} catch {}
|
||||
for (const [key, ws] of session.subscribers.entries()) {
|
||||
for (const [id, ws] of session.subscribers.entries()) {
|
||||
try {
|
||||
if (ws.data === key) ws.close()
|
||||
if (key(ws) === id) ws.close()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -263,16 +265,13 @@ export namespace Pty {
|
||||
}
|
||||
log.info("client connected to session", { id })
|
||||
|
||||
// Use ws.data as the unique key for this connection lifecycle.
|
||||
// If ws.data is undefined, fallback to ws object.
|
||||
const connectionKey = ws.data && typeof ws.data === "object" ? ws.data : ws
|
||||
const sub = key(ws)
|
||||
|
||||
// Optionally cleanup if the key somehow exists
|
||||
session.subscribers.delete(connectionKey)
|
||||
session.subscribers.set(connectionKey, ws)
|
||||
session.subscribers.delete(sub)
|
||||
session.subscribers.set(sub, ws)
|
||||
|
||||
const cleanup = () => {
|
||||
session.subscribers.delete(connectionKey)
|
||||
session.subscribers.delete(sub)
|
||||
}
|
||||
|
||||
const start = session.bufferCursor
|
||||
|
||||
@@ -1,39 +1,193 @@
|
||||
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
|
||||
import { runPromiseInstance } from "@/effect/runtime"
|
||||
import * as S from "./service"
|
||||
import type { QuestionID } from "./schema"
|
||||
import type { SessionID, MessageID } from "@/session/schema"
|
||||
import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { SessionID, MessageID } from "@/session/schema"
|
||||
import { Log } from "@/util/log"
|
||||
import z from "zod"
|
||||
import { QuestionID } from "./schema"
|
||||
|
||||
const log = Log.create({ service: "question" })
|
||||
|
||||
export namespace Question {
|
||||
export const Option = S.Option
|
||||
export type Option = S.Option
|
||||
export const Info = S.Info
|
||||
export type Info = S.Info
|
||||
export const Request = S.Request
|
||||
export type Request = S.Request
|
||||
export const Answer = S.Answer
|
||||
export type Answer = S.Answer
|
||||
export const Reply = S.Reply
|
||||
export type Reply = S.Reply
|
||||
export const Event = S.Event
|
||||
export const RejectedError = S.RejectedError
|
||||
// Schemas
|
||||
|
||||
export const Option = z
|
||||
.object({
|
||||
label: z.string().describe("Display text (1-5 words, concise)"),
|
||||
description: z.string().describe("Explanation of choice"),
|
||||
})
|
||||
.meta({ ref: "QuestionOption" })
|
||||
export type Option = z.infer<typeof Option>
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
question: z.string().describe("Complete question"),
|
||||
header: z.string().describe("Very short label (max 30 chars)"),
|
||||
options: z.array(Option).describe("Available choices"),
|
||||
multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
|
||||
custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"),
|
||||
})
|
||||
.meta({ ref: "QuestionInfo" })
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
export const Request = z
|
||||
.object({
|
||||
id: QuestionID.zod,
|
||||
sessionID: SessionID.zod,
|
||||
questions: z.array(Info).describe("Questions to ask"),
|
||||
tool: z
|
||||
.object({
|
||||
messageID: MessageID.zod,
|
||||
callID: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.meta({ ref: "QuestionRequest" })
|
||||
export type Request = z.infer<typeof Request>
|
||||
|
||||
export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" })
|
||||
export type Answer = z.infer<typeof Answer>
|
||||
|
||||
export const Reply = z.object({
|
||||
answers: z
|
||||
.array(Answer)
|
||||
.describe("User answers in order of questions (each answer is an array of selected labels)"),
|
||||
})
|
||||
export type Reply = z.infer<typeof Reply>
|
||||
|
||||
export const Event = {
|
||||
Asked: BusEvent.define("question.asked", Request),
|
||||
Replied: BusEvent.define(
|
||||
"question.replied",
|
||||
z.object({
|
||||
sessionID: SessionID.zod,
|
||||
requestID: QuestionID.zod,
|
||||
answers: z.array(Answer),
|
||||
}),
|
||||
),
|
||||
Rejected: BusEvent.define(
|
||||
"question.rejected",
|
||||
z.object({
|
||||
sessionID: SessionID.zod,
|
||||
requestID: QuestionID.zod,
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("QuestionRejectedError", {}) {
|
||||
override get message() {
|
||||
return "The user dismissed this question"
|
||||
}
|
||||
}
|
||||
|
||||
interface PendingEntry {
|
||||
info: Request
|
||||
deferred: Deferred.Deferred<Answer[], RejectedError>
|
||||
}
|
||||
|
||||
// Service
|
||||
|
||||
export interface Interface {
|
||||
readonly ask: (input: {
|
||||
sessionID: SessionID
|
||||
questions: Info[]
|
||||
tool?: { messageID: MessageID; callID: string }
|
||||
}) => Effect.Effect<Answer[], RejectedError>
|
||||
readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect<void>
|
||||
readonly reject: (requestID: QuestionID) => Effect.Effect<void>
|
||||
readonly list: () => Effect.Effect<Request[]>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Question") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const pending = new Map<QuestionID, PendingEntry>()
|
||||
|
||||
const ask = Effect.fn("Question.ask")(function* (input: {
|
||||
sessionID: SessionID
|
||||
questions: Info[]
|
||||
tool?: { messageID: MessageID; callID: string }
|
||||
}) {
|
||||
const id = QuestionID.ascending()
|
||||
log.info("asking", { id, questions: input.questions.length })
|
||||
|
||||
const deferred = yield* Deferred.make<Answer[], RejectedError>()
|
||||
const info: Request = {
|
||||
id,
|
||||
sessionID: input.sessionID,
|
||||
questions: input.questions,
|
||||
tool: input.tool,
|
||||
}
|
||||
pending.set(id, { info, deferred })
|
||||
Bus.publish(Event.Asked, info)
|
||||
|
||||
return yield* Effect.ensuring(
|
||||
Deferred.await(deferred),
|
||||
Effect.sync(() => {
|
||||
pending.delete(id)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const reply = Effect.fn("Question.reply")(function* (input: { requestID: QuestionID; answers: Answer[] }) {
|
||||
const existing = pending.get(input.requestID)
|
||||
if (!existing) {
|
||||
log.warn("reply for unknown request", { requestID: input.requestID })
|
||||
return
|
||||
}
|
||||
pending.delete(input.requestID)
|
||||
log.info("replied", { requestID: input.requestID, answers: input.answers })
|
||||
Bus.publish(Event.Replied, {
|
||||
sessionID: existing.info.sessionID,
|
||||
requestID: existing.info.id,
|
||||
answers: input.answers,
|
||||
})
|
||||
yield* Deferred.succeed(existing.deferred, input.answers)
|
||||
})
|
||||
|
||||
const reject = Effect.fn("Question.reject")(function* (requestID: QuestionID) {
|
||||
const existing = pending.get(requestID)
|
||||
if (!existing) {
|
||||
log.warn("reject for unknown request", { requestID })
|
||||
return
|
||||
}
|
||||
pending.delete(requestID)
|
||||
log.info("rejected", { requestID })
|
||||
Bus.publish(Event.Rejected, {
|
||||
sessionID: existing.info.sessionID,
|
||||
requestID: existing.info.id,
|
||||
})
|
||||
yield* Deferred.fail(existing.deferred, new RejectedError())
|
||||
})
|
||||
|
||||
const list = Effect.fn("Question.list")(function* () {
|
||||
return Array.from(pending.values(), (x) => x.info)
|
||||
})
|
||||
|
||||
return Service.of({ ask, reply, reject, list })
|
||||
}),
|
||||
)
|
||||
|
||||
export async function ask(input: {
|
||||
sessionID: SessionID
|
||||
questions: Info[]
|
||||
tool?: { messageID: MessageID; callID: string }
|
||||
}): Promise<Answer[]> {
|
||||
return runPromiseInstance(S.QuestionService.use((service) => service.ask(input)))
|
||||
return runPromiseInstance(Service.use((svc) => svc.ask(input)))
|
||||
}
|
||||
|
||||
export async function reply(input: { requestID: QuestionID; answers: Answer[] }): Promise<void> {
|
||||
return runPromiseInstance(S.QuestionService.use((service) => service.reply(input)))
|
||||
return runPromiseInstance(Service.use((svc) => svc.reply(input)))
|
||||
}
|
||||
|
||||
export async function reject(requestID: QuestionID): Promise<void> {
|
||||
return runPromiseInstance(S.QuestionService.use((service) => service.reject(requestID)))
|
||||
return runPromiseInstance(Service.use((svc) => svc.reject(requestID)))
|
||||
}
|
||||
|
||||
export async function list(): Promise<Request[]> {
|
||||
return runPromiseInstance(S.QuestionService.use((service) => service.list()))
|
||||
return runPromiseInstance(Service.use((svc) => svc.list()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
|
||||
import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { SessionID, MessageID } from "@/session/schema"
|
||||
import { Log } from "@/util/log"
|
||||
import z from "zod"
|
||||
import { QuestionID } from "./schema"
|
||||
|
||||
const log = Log.create({ service: "question" })
|
||||
|
||||
// --- Zod schemas (re-exported by facade) ---
|
||||
|
||||
export const Option = z
|
||||
.object({
|
||||
label: z.string().describe("Display text (1-5 words, concise)"),
|
||||
description: z.string().describe("Explanation of choice"),
|
||||
})
|
||||
.meta({ ref: "QuestionOption" })
|
||||
export type Option = z.infer<typeof Option>
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
question: z.string().describe("Complete question"),
|
||||
header: z.string().describe("Very short label (max 30 chars)"),
|
||||
options: z.array(Option).describe("Available choices"),
|
||||
multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
|
||||
custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"),
|
||||
})
|
||||
.meta({ ref: "QuestionInfo" })
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
export const Request = z
|
||||
.object({
|
||||
id: QuestionID.zod,
|
||||
sessionID: SessionID.zod,
|
||||
questions: z.array(Info).describe("Questions to ask"),
|
||||
tool: z
|
||||
.object({
|
||||
messageID: MessageID.zod,
|
||||
callID: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.meta({ ref: "QuestionRequest" })
|
||||
export type Request = z.infer<typeof Request>
|
||||
|
||||
export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" })
|
||||
export type Answer = z.infer<typeof Answer>
|
||||
|
||||
export const Reply = z.object({
|
||||
answers: z.array(Answer).describe("User answers in order of questions (each answer is an array of selected labels)"),
|
||||
})
|
||||
export type Reply = z.infer<typeof Reply>
|
||||
|
||||
export const Event = {
|
||||
Asked: BusEvent.define("question.asked", Request),
|
||||
Replied: BusEvent.define(
|
||||
"question.replied",
|
||||
z.object({
|
||||
sessionID: SessionID.zod,
|
||||
requestID: QuestionID.zod,
|
||||
answers: z.array(Answer),
|
||||
}),
|
||||
),
|
||||
Rejected: BusEvent.define(
|
||||
"question.rejected",
|
||||
z.object({
|
||||
sessionID: SessionID.zod,
|
||||
requestID: QuestionID.zod,
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("QuestionRejectedError", {}) {
|
||||
override get message() {
|
||||
return "The user dismissed this question"
|
||||
}
|
||||
}
|
||||
|
||||
// --- Effect service ---
|
||||
|
||||
interface PendingEntry {
|
||||
info: Request
|
||||
deferred: Deferred.Deferred<Answer[], RejectedError>
|
||||
}
|
||||
|
||||
export namespace QuestionService {
|
||||
export interface Service {
|
||||
readonly ask: (input: {
|
||||
sessionID: SessionID
|
||||
questions: Info[]
|
||||
tool?: { messageID: MessageID; callID: string }
|
||||
}) => Effect.Effect<Answer[], RejectedError>
|
||||
readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect<void>
|
||||
readonly reject: (requestID: QuestionID) => Effect.Effect<void>
|
||||
readonly list: () => Effect.Effect<Request[]>
|
||||
}
|
||||
}
|
||||
|
||||
export class QuestionService extends ServiceMap.Service<QuestionService, QuestionService.Service>()(
|
||||
"@opencode/Question",
|
||||
) {
|
||||
static readonly layer = Layer.effect(
|
||||
QuestionService,
|
||||
Effect.gen(function* () {
|
||||
const pending = new Map<QuestionID, PendingEntry>()
|
||||
|
||||
const ask = Effect.fn("QuestionService.ask")(function* (input: {
|
||||
sessionID: SessionID
|
||||
questions: Info[]
|
||||
tool?: { messageID: MessageID; callID: string }
|
||||
}) {
|
||||
const id = QuestionID.ascending()
|
||||
log.info("asking", { id, questions: input.questions.length })
|
||||
|
||||
const deferred = yield* Deferred.make<Answer[], RejectedError>()
|
||||
const info: Request = {
|
||||
id,
|
||||
sessionID: input.sessionID,
|
||||
questions: input.questions,
|
||||
tool: input.tool,
|
||||
}
|
||||
pending.set(id, { info, deferred })
|
||||
Bus.publish(Event.Asked, info)
|
||||
|
||||
return yield* Effect.ensuring(
|
||||
Deferred.await(deferred),
|
||||
Effect.sync(() => {
|
||||
pending.delete(id)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const reply = Effect.fn("QuestionService.reply")(function* (input: { requestID: QuestionID; answers: Answer[] }) {
|
||||
const existing = pending.get(input.requestID)
|
||||
if (!existing) {
|
||||
log.warn("reply for unknown request", { requestID: input.requestID })
|
||||
return
|
||||
}
|
||||
pending.delete(input.requestID)
|
||||
log.info("replied", { requestID: input.requestID, answers: input.answers })
|
||||
Bus.publish(Event.Replied, {
|
||||
sessionID: existing.info.sessionID,
|
||||
requestID: existing.info.id,
|
||||
answers: input.answers,
|
||||
})
|
||||
yield* Deferred.succeed(existing.deferred, input.answers)
|
||||
})
|
||||
|
||||
const reject = Effect.fn("QuestionService.reject")(function* (requestID: QuestionID) {
|
||||
const existing = pending.get(requestID)
|
||||
if (!existing) {
|
||||
log.warn("reject for unknown request", { requestID })
|
||||
return
|
||||
}
|
||||
pending.delete(requestID)
|
||||
log.info("rejected", { requestID })
|
||||
Bus.publish(Event.Rejected, {
|
||||
sessionID: existing.info.sessionID,
|
||||
requestID: existing.info.id,
|
||||
})
|
||||
yield* Deferred.fail(existing.deferred, new RejectedError())
|
||||
})
|
||||
|
||||
const list = Effect.fn("QuestionService.list")(function* () {
|
||||
return Array.from(pending.values(), (x) => x.info)
|
||||
})
|
||||
|
||||
return QuestionService.of({ ask, reply, reject, list })
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export const ProjectRoutes = lazy(() =>
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const projects = await Project.list()
|
||||
const projects = Project.list()
|
||||
return c.json(projects)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import { upgradeWebSocket } from "hono/bun"
|
||||
import type { UpgradeWebSocket } from "hono/ws"
|
||||
import z from "zod"
|
||||
import { Pty } from "@/pty"
|
||||
import { PtyID } from "@/pty/schema"
|
||||
import { NotFoundError } from "../../storage/db"
|
||||
import { errors } from "../error"
|
||||
import { lazy } from "../../util/lazy"
|
||||
|
||||
export const PtyRoutes = lazy(() =>
|
||||
new Hono()
|
||||
export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
||||
return new Hono()
|
||||
.get(
|
||||
"/",
|
||||
describeRoute({
|
||||
@@ -197,5 +196,5 @@ export const PtyRoutes = lazy(() =>
|
||||
},
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { LSP } from "../lsp"
|
||||
import { Format } from "../format"
|
||||
import { TuiRoutes } from "./routes/tui"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Vcs, VcsService } from "../project/vcs"
|
||||
import { Vcs } from "../project/vcs"
|
||||
import { runPromiseInstance } from "@/effect/runtime"
|
||||
import { Agent } from "../agent/agent"
|
||||
import { Skill } from "../skill/skill"
|
||||
@@ -28,7 +28,7 @@ import { ProviderID } from "../provider/schema"
|
||||
import { WorkspaceRouterMiddleware } from "../control-plane/workspace-router-middleware"
|
||||
import { ProjectRoutes } from "./routes/project"
|
||||
import { SessionRoutes } from "./routes/session"
|
||||
import { PtyRoutes } from "./routes/pty"
|
||||
// import { PtyRoutes } from "./routes/pty"
|
||||
import { McpRoutes } from "./routes/mcp"
|
||||
import { FileRoutes } from "./routes/file"
|
||||
import { ConfigRoutes } from "./routes/config"
|
||||
@@ -37,7 +37,8 @@ import { ProviderRoutes } from "./routes/provider"
|
||||
import { InstanceBootstrap } from "../project/bootstrap"
|
||||
import { NotFoundError } from "../storage/db"
|
||||
import type { ContentfulStatusCode } from "hono/utils/http-status"
|
||||
import { websocket } from "hono/bun"
|
||||
import { createAdaptorServer, type ServerType } from "@hono/node-server"
|
||||
import { createNodeWebSocket } from "@hono/node-ws"
|
||||
import { HTTPException } from "hono/http-exception"
|
||||
import { errors } from "./error"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
@@ -51,13 +52,20 @@ import { lazy } from "@/util/lazy"
|
||||
globalThis.AI_SDK_LOG_WARNINGS = false
|
||||
|
||||
export namespace Server {
|
||||
const log = Log.create({ service: "server" })
|
||||
export type Listener = {
|
||||
hostname: string
|
||||
port: number
|
||||
url: URL
|
||||
stop: (close?: boolean) => Promise<void>
|
||||
}
|
||||
|
||||
export const Default = lazy(() => createApp({}))
|
||||
export const Default = lazy(() => create({}).app)
|
||||
|
||||
export const createApp = (opts: { cors?: string[] }): Hono => {
|
||||
function create(opts: { cors?: string[] }) {
|
||||
const log = Log.create({ service: "server" })
|
||||
const app = new Hono()
|
||||
return app
|
||||
const ws = createNodeWebSocket({ app })
|
||||
const route = app
|
||||
.onError((err, c) => {
|
||||
log.error("failed", {
|
||||
error: err,
|
||||
@@ -243,7 +251,6 @@ export namespace Server {
|
||||
),
|
||||
)
|
||||
.route("/project", ProjectRoutes())
|
||||
.route("/pty", PtyRoutes())
|
||||
.route("/config", ConfigRoutes())
|
||||
.route("/experimental", ExperimentalRoutes())
|
||||
.route("/session", SessionRoutes())
|
||||
@@ -332,7 +339,7 @@ export namespace Server {
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const branch = await runPromiseInstance(VcsService.use((s) => s.branch()))
|
||||
const branch = await runPromiseInstance(Vcs.Service.use((s) => s.branch()))
|
||||
return c.json({
|
||||
branch,
|
||||
})
|
||||
@@ -556,6 +563,7 @@ export namespace Server {
|
||||
})
|
||||
},
|
||||
)
|
||||
// .route("/pty", PtyRoutes(ws.upgradeWebSocket))
|
||||
.all("/*", async (c) => {
|
||||
const path = c.req.path
|
||||
|
||||
@@ -572,6 +580,11 @@ export namespace Server {
|
||||
)
|
||||
return response
|
||||
})
|
||||
|
||||
return {
|
||||
app: route as Hono,
|
||||
ws,
|
||||
}
|
||||
}
|
||||
|
||||
export async function openapi() {
|
||||
@@ -589,52 +602,86 @@ export namespace Server {
|
||||
return result
|
||||
}
|
||||
|
||||
/** @deprecated do not use this dumb shit */
|
||||
export let url: URL
|
||||
|
||||
export function listen(opts: {
|
||||
export async function listen(opts: {
|
||||
port: number
|
||||
hostname: string
|
||||
mdns?: boolean
|
||||
mdnsDomain?: string
|
||||
cors?: string[]
|
||||
}) {
|
||||
url = new URL(`http://${opts.hostname}:${opts.port}`)
|
||||
const app = createApp(opts)
|
||||
const args = {
|
||||
hostname: opts.hostname,
|
||||
idleTimeout: 0,
|
||||
fetch: app.fetch,
|
||||
websocket: websocket,
|
||||
} as const
|
||||
const tryServe = (port: number) => {
|
||||
try {
|
||||
return Bun.serve({ ...args, port })
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}): Promise<Listener> {
|
||||
const log = Log.create({ service: "server" })
|
||||
const built = create({
|
||||
...opts,
|
||||
})
|
||||
const start = (port: number) =>
|
||||
new Promise<ServerType>((resolve, reject) => {
|
||||
const server = createAdaptorServer({ fetch: built.app.fetch })
|
||||
built.ws.injectWebSocket(server)
|
||||
const fail = (err: Error) => {
|
||||
cleanup()
|
||||
reject(err)
|
||||
}
|
||||
const ready = () => {
|
||||
cleanup()
|
||||
resolve(server)
|
||||
}
|
||||
const cleanup = () => {
|
||||
server.off("error", fail)
|
||||
server.off("listening", ready)
|
||||
}
|
||||
server.once("error", fail)
|
||||
server.once("listening", ready)
|
||||
server.listen(port, opts.hostname)
|
||||
})
|
||||
|
||||
const server = opts.port === 0 ? await start(4096).catch(() => start(0)) : await start(opts.port)
|
||||
const addr = server.address()
|
||||
if (!addr || typeof addr === "string") {
|
||||
throw new Error(`Failed to resolve server address for port ${opts.port}`)
|
||||
}
|
||||
const server = opts.port === 0 ? (tryServe(4096) ?? tryServe(0)) : tryServe(opts.port)
|
||||
if (!server) throw new Error(`Failed to start server on port ${opts.port}`)
|
||||
|
||||
const url = new URL("http://localhost")
|
||||
url.hostname = opts.hostname
|
||||
url.port = String(addr.port)
|
||||
|
||||
const shouldPublishMDNS =
|
||||
opts.mdns &&
|
||||
server.port &&
|
||||
addr.port &&
|
||||
opts.hostname !== "127.0.0.1" &&
|
||||
opts.hostname !== "localhost" &&
|
||||
opts.hostname !== "::1"
|
||||
if (shouldPublishMDNS) {
|
||||
MDNS.publish(server.port!, opts.mdnsDomain)
|
||||
MDNS.publish(addr.port, opts.mdnsDomain)
|
||||
} else if (opts.mdns) {
|
||||
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
|
||||
}
|
||||
|
||||
const originalStop = server.stop.bind(server)
|
||||
server.stop = async (closeActiveConnections?: boolean) => {
|
||||
if (shouldPublishMDNS) MDNS.unpublish()
|
||||
return originalStop(closeActiveConnections)
|
||||
let closing: Promise<void> | undefined
|
||||
return {
|
||||
hostname: opts.hostname,
|
||||
port: addr.port,
|
||||
url,
|
||||
stop(close?: boolean) {
|
||||
closing ??= new Promise((resolve, reject) => {
|
||||
if (shouldPublishMDNS) MDNS.unpublish()
|
||||
server.close((err) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
if (close) {
|
||||
if ("closeAllConnections" in server && typeof server.closeAllConnections === "function") {
|
||||
server.closeAllConnections()
|
||||
}
|
||||
if ("closeIdleConnections" in server && typeof server.closeIdleConnections === "function") {
|
||||
server.closeIdleConnections()
|
||||
}
|
||||
}
|
||||
})
|
||||
return closing
|
||||
},
|
||||
}
|
||||
|
||||
return server
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { STATUS_CODES } from "http"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import { ProviderError } from "@/provider/error"
|
||||
import { iife } from "@/util/iife"
|
||||
import { type SystemError } from "bun"
|
||||
import type { SystemError } from "bun"
|
||||
import type { Provider } from "@/provider/provider"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@ import { Flag } from "../flag/flag"
|
||||
import { ulid } from "ulid"
|
||||
import { spawn } from "child_process"
|
||||
import { Command } from "../command"
|
||||
import { $ } from "bun"
|
||||
import { pathToFileURL, fileURLToPath } from "url"
|
||||
import { ConfigMarkdown } from "../config/markdown"
|
||||
import { SessionSummary } from "./summary"
|
||||
@@ -48,6 +47,7 @@ import { iife } from "@/util/iife"
|
||||
import { Shell } from "@/shell/shell"
|
||||
import { Truncate } from "@/tool/truncate"
|
||||
import { decodeDataUrl } from "@/util/data-url"
|
||||
import { Process } from "@/util/process"
|
||||
|
||||
// @ts-ignore
|
||||
globalThis.AI_SDK_LOG_WARNINGS = false
|
||||
@@ -318,11 +318,7 @@ export namespace SessionPrompt {
|
||||
}
|
||||
|
||||
if (!lastUser) throw new Error("No user message found in stream. This should never happen.")
|
||||
if (
|
||||
lastAssistant?.finish &&
|
||||
!["tool-calls", "unknown"].includes(lastAssistant.finish) &&
|
||||
lastUser.id < lastAssistant.id
|
||||
) {
|
||||
if (shouldExitLoop(lastUser, lastAssistant)) {
|
||||
log.info("exiting loop", { sessionID })
|
||||
break
|
||||
}
|
||||
@@ -1812,15 +1808,13 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
template = template + "\n\n" + input.arguments
|
||||
}
|
||||
|
||||
const shell = ConfigMarkdown.shell(template)
|
||||
if (shell.length > 0) {
|
||||
const shellMatches = ConfigMarkdown.shell(template)
|
||||
if (shellMatches.length > 0) {
|
||||
const sh = Shell.preferred()
|
||||
const results = await Promise.all(
|
||||
shell.map(async ([, cmd]) => {
|
||||
try {
|
||||
return await $`${{ raw: cmd }}`.quiet().nothrow().text()
|
||||
} catch (error) {
|
||||
return `Error executing command: ${error instanceof Error ? error.message : String(error)}`
|
||||
}
|
||||
shellMatches.map(async ([, cmd]) => {
|
||||
const out = await Process.text([cmd], { shell: sh, nothrow: true })
|
||||
return out.text
|
||||
}),
|
||||
)
|
||||
let index = 0
|
||||
@@ -1993,4 +1987,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
return Session.setTitle({ sessionID: input.session.id, title })
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal Exported for testing — determines whether the prompt loop should exit */
|
||||
export function shouldExitLoop(
|
||||
lastUser: MessageV2.User | undefined,
|
||||
lastAssistant: MessageV2.Assistant | undefined,
|
||||
): boolean {
|
||||
if (!lastUser) return false
|
||||
if (!lastAssistant?.finish) return false
|
||||
if (["tool-calls", "unknown"].includes(lastAssistant.finish)) return false
|
||||
return lastAssistant.parentID === lastUser.id
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,116 +1,117 @@
|
||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { Effect, FileSystem, Layer, Path, Schema, ServiceMap } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||
import { Global } from "../global"
|
||||
import { Log } from "../util/log"
|
||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||
|
||||
class IndexSkill extends Schema.Class<IndexSkill>("IndexSkill")({
|
||||
name: Schema.String,
|
||||
files: Schema.Array(Schema.String),
|
||||
}) {}
|
||||
export namespace Discovery {
|
||||
const skillConcurrency = 4
|
||||
const fileConcurrency = 8
|
||||
|
||||
class Index extends Schema.Class<Index>("Index")({
|
||||
skills: Schema.Array(IndexSkill),
|
||||
}) {}
|
||||
class IndexSkill extends Schema.Class<IndexSkill>("IndexSkill")({
|
||||
name: Schema.String,
|
||||
files: Schema.Array(Schema.String),
|
||||
}) {}
|
||||
|
||||
const skillConcurrency = 4
|
||||
const fileConcurrency = 8
|
||||
class Index extends Schema.Class<Index>("Index")({
|
||||
skills: Schema.Array(IndexSkill),
|
||||
}) {}
|
||||
|
||||
export namespace DiscoveryService {
|
||||
export interface Service {
|
||||
export interface Interface {
|
||||
readonly pull: (url: string) => Effect.Effect<string[]>
|
||||
}
|
||||
}
|
||||
|
||||
export class DiscoveryService extends ServiceMap.Service<DiscoveryService, DiscoveryService.Service>()(
|
||||
"@opencode/SkillDiscovery",
|
||||
) {
|
||||
static readonly layer = Layer.effect(
|
||||
DiscoveryService,
|
||||
Effect.gen(function* () {
|
||||
const log = Log.create({ service: "skill-discovery" })
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const path = yield* Path.Path
|
||||
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
|
||||
const cache = path.join(Global.Path.cache, "skills")
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SkillDiscovery") {}
|
||||
|
||||
const download = Effect.fn("DiscoveryService.download")(function* (url: string, dest: string) {
|
||||
if (yield* fs.exists(dest).pipe(Effect.orDie)) return true
|
||||
export const layer: Layer.Layer<Service, never, FileSystem.FileSystem | Path.Path | HttpClient.HttpClient> =
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const log = Log.create({ service: "skill-discovery" })
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const path = yield* Path.Path
|
||||
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
|
||||
const cache = path.join(Global.Path.cache, "skills")
|
||||
|
||||
return yield* HttpClientRequest.get(url).pipe(
|
||||
http.execute,
|
||||
Effect.flatMap((res) => res.arrayBuffer),
|
||||
Effect.flatMap((body) =>
|
||||
fs
|
||||
.makeDirectory(path.dirname(dest), { recursive: true })
|
||||
.pipe(Effect.flatMap(() => fs.writeFile(dest, new Uint8Array(body)))),
|
||||
),
|
||||
Effect.as(true),
|
||||
Effect.catch((err) =>
|
||||
Effect.sync(() => {
|
||||
log.error("failed to download", { url, err })
|
||||
return false
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
const download = Effect.fn("Discovery.download")(function* (url: string, dest: string) {
|
||||
if (yield* fs.exists(dest).pipe(Effect.orDie)) return true
|
||||
|
||||
const pull: DiscoveryService.Service["pull"] = Effect.fn("DiscoveryService.pull")(function* (url: string) {
|
||||
const base = url.endsWith("/") ? url : `${url}/`
|
||||
const index = new URL("index.json", base).href
|
||||
const host = base.slice(0, -1)
|
||||
|
||||
log.info("fetching index", { url: index })
|
||||
|
||||
const data = yield* HttpClientRequest.get(index).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
http.execute,
|
||||
Effect.flatMap(HttpClientResponse.schemaBodyJson(Index)),
|
||||
Effect.catch((err) =>
|
||||
Effect.sync(() => {
|
||||
log.error("failed to fetch index", { url: index, err })
|
||||
return null
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
if (!data) return []
|
||||
|
||||
const list = data.skills.filter((skill) => {
|
||||
if (!skill.files.includes("SKILL.md")) {
|
||||
log.warn("skill entry missing SKILL.md", { url: index, skill: skill.name })
|
||||
return false
|
||||
}
|
||||
return true
|
||||
return yield* HttpClientRequest.get(url).pipe(
|
||||
http.execute,
|
||||
Effect.flatMap((res) => res.arrayBuffer),
|
||||
Effect.flatMap((body) =>
|
||||
fs
|
||||
.makeDirectory(path.dirname(dest), { recursive: true })
|
||||
.pipe(Effect.flatMap(() => fs.writeFile(dest, new Uint8Array(body)))),
|
||||
),
|
||||
Effect.as(true),
|
||||
Effect.catch((err) =>
|
||||
Effect.sync(() => {
|
||||
log.error("failed to download", { url, err })
|
||||
return false
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const dirs = yield* Effect.forEach(
|
||||
list,
|
||||
(skill) =>
|
||||
Effect.gen(function* () {
|
||||
const root = path.join(cache, skill.name)
|
||||
const pull = Effect.fn("Discovery.pull")(function* (url: string) {
|
||||
const base = url.endsWith("/") ? url : `${url}/`
|
||||
const index = new URL("index.json", base).href
|
||||
const host = base.slice(0, -1)
|
||||
|
||||
yield* Effect.forEach(
|
||||
skill.files,
|
||||
(file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)),
|
||||
{ concurrency: fileConcurrency },
|
||||
)
|
||||
log.info("fetching index", { url: index })
|
||||
|
||||
const md = path.join(root, "SKILL.md")
|
||||
return (yield* fs.exists(md).pipe(Effect.orDie)) ? root : null
|
||||
}),
|
||||
{ concurrency: skillConcurrency },
|
||||
)
|
||||
const data = yield* HttpClientRequest.get(index).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
http.execute,
|
||||
Effect.flatMap(HttpClientResponse.schemaBodyJson(Index)),
|
||||
Effect.catch((err) =>
|
||||
Effect.sync(() => {
|
||||
log.error("failed to fetch index", { url: index, err })
|
||||
return null
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
return dirs.filter((dir): dir is string => dir !== null)
|
||||
})
|
||||
if (!data) return []
|
||||
|
||||
return DiscoveryService.of({ pull })
|
||||
}),
|
||||
)
|
||||
const list = data.skills.filter((skill) => {
|
||||
if (!skill.files.includes("SKILL.md")) {
|
||||
log.warn("skill entry missing SKILL.md", { url: index, skill: skill.name })
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
static readonly defaultLayer = DiscoveryService.layer.pipe(
|
||||
const dirs = yield* Effect.forEach(
|
||||
list,
|
||||
(skill) =>
|
||||
Effect.gen(function* () {
|
||||
const root = path.join(cache, skill.name)
|
||||
|
||||
yield* Effect.forEach(
|
||||
skill.files,
|
||||
(file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)),
|
||||
{
|
||||
concurrency: fileConcurrency,
|
||||
},
|
||||
)
|
||||
|
||||
const md = path.join(root, "SKILL.md")
|
||||
return (yield* fs.exists(md).pipe(Effect.orDie)) ? root : null
|
||||
}),
|
||||
{ concurrency: skillConcurrency },
|
||||
)
|
||||
|
||||
return dirs.filter((dir): dir is string => dir !== null)
|
||||
})
|
||||
|
||||
return Service.of({ pull })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(NodeFileSystem.layer),
|
||||
Layer.provide(NodePath.layer),
|
||||
|
||||
@@ -1,34 +1,30 @@
|
||||
import z from "zod"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import { Config } from "../config/config"
|
||||
import { Instance } from "../project/instance"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { ConfigMarkdown } from "../config/markdown"
|
||||
import { Log } from "../util/log"
|
||||
import { Global } from "@/global"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Bus } from "@/bus"
|
||||
import { DiscoveryService } from "./discovery"
|
||||
import { Glob } from "../util/glob"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import type { Agent } from "@/agent/agent"
|
||||
import { PermissionNext } from "@/permission"
|
||||
import { InstanceContext } from "@/effect/instance-context"
|
||||
import z from "zod"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import type { Agent } from "@/agent/agent"
|
||||
import { Bus } from "@/bus"
|
||||
import { InstanceContext } from "@/effect/instance-context"
|
||||
import { runPromiseInstance } from "@/effect/runtime"
|
||||
|
||||
const log = Log.create({ service: "skill" })
|
||||
|
||||
// External skill directories to search for (project-level and global)
|
||||
// These follow the directory layout used by Claude Code and other agents.
|
||||
const EXTERNAL_DIRS = [".claude", ".agents"]
|
||||
const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md"
|
||||
const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"
|
||||
const SKILL_PATTERN = "**/SKILL.md"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Global } from "@/global"
|
||||
import { PermissionNext } from "@/permission"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Config } from "../config/config"
|
||||
import { ConfigMarkdown } from "../config/markdown"
|
||||
import { Glob } from "../util/glob"
|
||||
import { Log } from "../util/log"
|
||||
import { Discovery } from "./discovery"
|
||||
|
||||
export namespace Skill {
|
||||
const log = Log.create({ service: "skill" })
|
||||
const EXTERNAL_DIRS = [".claude", ".agents"]
|
||||
const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md"
|
||||
const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"
|
||||
const SKILL_PATTERN = "**/SKILL.md"
|
||||
|
||||
export const Info = z.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
@@ -55,213 +51,205 @@ export namespace Skill {
|
||||
}),
|
||||
)
|
||||
|
||||
type State = {
|
||||
skills: Record<string, Info>
|
||||
dirs: Set<string>
|
||||
task?: Promise<void>
|
||||
}
|
||||
|
||||
type Cache = State & {
|
||||
ensure: () => Promise<void>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly get: (name: string) => Effect.Effect<Info | undefined>
|
||||
readonly all: () => Effect.Effect<Info[]>
|
||||
readonly dirs: () => Effect.Effect<string[]>
|
||||
readonly available: (agent?: Agent.Info) => Effect.Effect<Info[]>
|
||||
}
|
||||
|
||||
const add = async (state: State, match: string) => {
|
||||
const md = await ConfigMarkdown.parse(match).catch(async (err) => {
|
||||
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
||||
? err.data.message
|
||||
: `Failed to parse skill ${match}`
|
||||
const { Session } = await import("@/session")
|
||||
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
|
||||
log.error("failed to load skill", { skill: match, err })
|
||||
return undefined
|
||||
})
|
||||
|
||||
if (!md) return
|
||||
|
||||
const parsed = Info.pick({ name: true, description: true }).safeParse(md.data)
|
||||
if (!parsed.success) return
|
||||
|
||||
if (state.skills[parsed.data.name]) {
|
||||
log.warn("duplicate skill name", {
|
||||
name: parsed.data.name,
|
||||
existing: state.skills[parsed.data.name].location,
|
||||
duplicate: match,
|
||||
})
|
||||
}
|
||||
|
||||
state.dirs.add(path.dirname(match))
|
||||
state.skills[parsed.data.name] = {
|
||||
name: parsed.data.name,
|
||||
description: parsed.data.description,
|
||||
location: match,
|
||||
content: md.content,
|
||||
}
|
||||
}
|
||||
|
||||
const scan = async (state: State, root: string, pattern: string, opts?: { dot?: boolean; scope?: string }) => {
|
||||
return Glob.scan(pattern, {
|
||||
cwd: root,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
symlink: true,
|
||||
dot: opts?.dot,
|
||||
})
|
||||
.then((matches) => Promise.all(matches.map((match) => add(state, match))))
|
||||
.catch((error) => {
|
||||
if (!opts?.scope) throw error
|
||||
log.error(`failed to scan ${opts.scope} skills`, { dir: root, error })
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: Migrate to Effect
|
||||
const create = (instance: InstanceContext.Shape, discovery: Discovery.Interface): Cache => {
|
||||
const state: State = {
|
||||
skills: {},
|
||||
dirs: new Set<string>(),
|
||||
}
|
||||
|
||||
const load = async () => {
|
||||
if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
|
||||
for (const dir of EXTERNAL_DIRS) {
|
||||
const root = path.join(Global.Path.home, dir)
|
||||
if (!(await Filesystem.isDir(root))) continue
|
||||
await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
|
||||
}
|
||||
|
||||
for await (const root of Filesystem.up({
|
||||
targets: EXTERNAL_DIRS,
|
||||
start: instance.directory,
|
||||
stop: instance.project.worktree,
|
||||
})) {
|
||||
await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" })
|
||||
}
|
||||
}
|
||||
|
||||
for (const dir of await Config.directories()) {
|
||||
await scan(state, dir, OPENCODE_SKILL_PATTERN)
|
||||
}
|
||||
|
||||
const cfg = await Config.get()
|
||||
for (const item of cfg.skills?.paths ?? []) {
|
||||
const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item
|
||||
const dir = path.isAbsolute(expanded) ? expanded : path.join(instance.directory, expanded)
|
||||
if (!(await Filesystem.isDir(dir))) {
|
||||
log.warn("skill path not found", { path: dir })
|
||||
continue
|
||||
}
|
||||
|
||||
await scan(state, dir, SKILL_PATTERN)
|
||||
}
|
||||
|
||||
for (const url of cfg.skills?.urls ?? []) {
|
||||
for (const dir of await Effect.runPromise(discovery.pull(url))) {
|
||||
state.dirs.add(dir)
|
||||
await scan(state, dir, SKILL_PATTERN)
|
||||
}
|
||||
}
|
||||
|
||||
log.info("init", { count: Object.keys(state.skills).length })
|
||||
}
|
||||
|
||||
const ensure = () => {
|
||||
if (state.task) return state.task
|
||||
state.task = load().catch((err) => {
|
||||
state.task = undefined
|
||||
throw err
|
||||
})
|
||||
return state.task
|
||||
}
|
||||
|
||||
return { ...state, ensure }
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Skill") {}
|
||||
|
||||
export const layer: Layer.Layer<Service, never, InstanceContext | Discovery.Service> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* InstanceContext
|
||||
const discovery = yield* Discovery.Service
|
||||
const state = create(instance, discovery)
|
||||
|
||||
const get = Effect.fn("Skill.get")(function* (name: string) {
|
||||
yield* Effect.promise(() => state.ensure())
|
||||
return state.skills[name]
|
||||
})
|
||||
|
||||
const all = Effect.fn("Skill.all")(function* () {
|
||||
yield* Effect.promise(() => state.ensure())
|
||||
return Object.values(state.skills)
|
||||
})
|
||||
|
||||
const dirs = Effect.fn("Skill.dirs")(function* () {
|
||||
yield* Effect.promise(() => state.ensure())
|
||||
return Array.from(state.dirs)
|
||||
})
|
||||
|
||||
const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) {
|
||||
yield* Effect.promise(() => state.ensure())
|
||||
const list = Object.values(state.skills)
|
||||
if (!agent) return list
|
||||
return list.filter((skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny")
|
||||
})
|
||||
|
||||
return Service.of({ get, all, dirs, available })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer: Layer.Layer<Service, never, InstanceContext> = layer.pipe(
|
||||
Layer.provide(Discovery.defaultLayer),
|
||||
)
|
||||
|
||||
export async function get(name: string) {
|
||||
return runPromiseInstance(SkillService.use((s) => s.get(name)))
|
||||
return runPromiseInstance(Service.use((skill) => skill.get(name)))
|
||||
}
|
||||
|
||||
export async function all() {
|
||||
return runPromiseInstance(SkillService.use((s) => s.all()))
|
||||
return runPromiseInstance(Service.use((skill) => skill.all()))
|
||||
}
|
||||
|
||||
export async function dirs() {
|
||||
return runPromiseInstance(SkillService.use((s) => s.dirs()))
|
||||
return runPromiseInstance(Service.use((skill) => skill.dirs()))
|
||||
}
|
||||
|
||||
export async function available(agent?: Agent.Info) {
|
||||
return runPromiseInstance(SkillService.use((s) => s.available(agent)))
|
||||
return runPromiseInstance(Service.use((skill) => skill.available(agent)))
|
||||
}
|
||||
|
||||
export function fmt(list: Info[], opts: { verbose: boolean }) {
|
||||
if (list.length === 0) {
|
||||
return "No skills are currently available."
|
||||
}
|
||||
if (list.length === 0) return "No skills are currently available."
|
||||
|
||||
if (opts.verbose) {
|
||||
return [
|
||||
"<available_skills>",
|
||||
...list.flatMap((skill) => [
|
||||
` <skill>`,
|
||||
" <skill>",
|
||||
` <name>${skill.name}</name>`,
|
||||
` <description>${skill.description}</description>`,
|
||||
` <location>${pathToFileURL(skill.location).href}</location>`,
|
||||
` </skill>`,
|
||||
" </skill>",
|
||||
]),
|
||||
"</available_skills>",
|
||||
].join("\n")
|
||||
}
|
||||
return ["## Available Skills", ...list.flatMap((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n")
|
||||
|
||||
return ["## Available Skills", ...list.map((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
export namespace SkillService {
|
||||
export interface Service {
|
||||
readonly get: (name: string) => Effect.Effect<Skill.Info | undefined>
|
||||
readonly all: () => Effect.Effect<Skill.Info[]>
|
||||
readonly dirs: () => Effect.Effect<string[]>
|
||||
readonly available: (agent?: Agent.Info) => Effect.Effect<Skill.Info[]>
|
||||
}
|
||||
}
|
||||
|
||||
export class SkillService extends ServiceMap.Service<SkillService, SkillService.Service>()("@opencode/Skill") {
|
||||
static readonly layer = Layer.effect(
|
||||
SkillService,
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* InstanceContext
|
||||
const discovery = yield* DiscoveryService
|
||||
|
||||
const skills: Record<string, Skill.Info> = {}
|
||||
const skillDirs = new Set<string>()
|
||||
let task: Promise<void> | undefined
|
||||
|
||||
const addSkill = async (match: string) => {
|
||||
const md = await ConfigMarkdown.parse(match).catch(async (err) => {
|
||||
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
||||
? err.data.message
|
||||
: `Failed to parse skill ${match}`
|
||||
const { Session } = await import("@/session")
|
||||
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
|
||||
log.error("failed to load skill", { skill: match, err })
|
||||
return undefined
|
||||
})
|
||||
|
||||
if (!md) return
|
||||
|
||||
const parsed = Skill.Info.pick({ name: true, description: true }).safeParse(md.data)
|
||||
if (!parsed.success) return
|
||||
|
||||
// Warn on duplicate skill names
|
||||
if (skills[parsed.data.name]) {
|
||||
log.warn("duplicate skill name", {
|
||||
name: parsed.data.name,
|
||||
existing: skills[parsed.data.name].location,
|
||||
duplicate: match,
|
||||
})
|
||||
}
|
||||
|
||||
skillDirs.add(path.dirname(match))
|
||||
|
||||
skills[parsed.data.name] = {
|
||||
name: parsed.data.name,
|
||||
description: parsed.data.description,
|
||||
location: match,
|
||||
content: md.content,
|
||||
}
|
||||
}
|
||||
|
||||
const scanExternal = async (root: string, scope: "global" | "project") => {
|
||||
return Glob.scan(EXTERNAL_SKILL_PATTERN, {
|
||||
cwd: root,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
dot: true,
|
||||
symlink: true,
|
||||
})
|
||||
.then((matches) => Promise.all(matches.map(addSkill)))
|
||||
.catch((error) => {
|
||||
log.error(`failed to scan ${scope} skills`, { dir: root, error })
|
||||
})
|
||||
}
|
||||
|
||||
function ensureScanned() {
|
||||
if (task) return task
|
||||
task = (async () => {
|
||||
// Scan external skill directories (.claude/skills/, .agents/skills/, etc.)
|
||||
// Load global (home) first, then project-level (so project-level overwrites)
|
||||
if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
|
||||
for (const dir of EXTERNAL_DIRS) {
|
||||
const root = path.join(Global.Path.home, dir)
|
||||
if (!(await Filesystem.isDir(root))) continue
|
||||
await scanExternal(root, "global")
|
||||
}
|
||||
|
||||
for await (const root of Filesystem.up({
|
||||
targets: EXTERNAL_DIRS,
|
||||
start: instance.directory,
|
||||
stop: instance.project.worktree,
|
||||
})) {
|
||||
await scanExternal(root, "project")
|
||||
}
|
||||
}
|
||||
|
||||
// Scan .opencode/skill/ directories
|
||||
for (const dir of await Config.directories()) {
|
||||
const matches = await Glob.scan(OPENCODE_SKILL_PATTERN, {
|
||||
cwd: dir,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
symlink: true,
|
||||
})
|
||||
for (const match of matches) {
|
||||
await addSkill(match)
|
||||
}
|
||||
}
|
||||
|
||||
// Scan additional skill paths from config
|
||||
const config = await Config.get()
|
||||
for (const skillPath of config.skills?.paths ?? []) {
|
||||
const expanded = skillPath.startsWith("~/") ? path.join(os.homedir(), skillPath.slice(2)) : skillPath
|
||||
const resolved = path.isAbsolute(expanded) ? expanded : path.join(instance.directory, expanded)
|
||||
if (!(await Filesystem.isDir(resolved))) {
|
||||
log.warn("skill path not found", { path: resolved })
|
||||
continue
|
||||
}
|
||||
const matches = await Glob.scan(SKILL_PATTERN, {
|
||||
cwd: resolved,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
symlink: true,
|
||||
})
|
||||
for (const match of matches) {
|
||||
await addSkill(match)
|
||||
}
|
||||
}
|
||||
|
||||
// Download and load skills from URLs
|
||||
for (const url of config.skills?.urls ?? []) {
|
||||
const list = await Effect.runPromise(discovery.pull(url))
|
||||
for (const dir of list) {
|
||||
skillDirs.add(dir)
|
||||
const matches = await Glob.scan(SKILL_PATTERN, {
|
||||
cwd: dir,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
symlink: true,
|
||||
})
|
||||
for (const match of matches) {
|
||||
await addSkill(match)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.info("init", { count: Object.keys(skills).length })
|
||||
})().catch((err) => {
|
||||
task = undefined
|
||||
throw err
|
||||
})
|
||||
return task
|
||||
}
|
||||
|
||||
return SkillService.of({
|
||||
get: Effect.fn("SkillService.get")(function* (name: string) {
|
||||
yield* Effect.promise(() => ensureScanned())
|
||||
return skills[name]
|
||||
}),
|
||||
all: Effect.fn("SkillService.all")(function* () {
|
||||
yield* Effect.promise(() => ensureScanned())
|
||||
return Object.values(skills)
|
||||
}),
|
||||
dirs: Effect.fn("SkillService.dirs")(function* () {
|
||||
yield* Effect.promise(() => ensureScanned())
|
||||
return Array.from(skillDirs)
|
||||
}),
|
||||
available: Effect.fn("SkillService.available")(function* (agent?: Agent.Info) {
|
||||
yield* Effect.promise(() => ensureScanned())
|
||||
const list = Object.values(skills)
|
||||
if (!agent) return list
|
||||
return list.filter(
|
||||
(skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny",
|
||||
)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(DiscoveryService.defaultLayer))
|
||||
}
|
||||
|
||||
@@ -9,20 +9,6 @@ import { Config } from "../config/config"
|
||||
import { Global } from "../global"
|
||||
import { Log } from "../util/log"
|
||||
|
||||
const log = Log.create({ service: "snapshot" })
|
||||
const PRUNE = "7.days"
|
||||
|
||||
// Common git config flags shared across snapshot operations
|
||||
const GIT_CORE = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"]
|
||||
const GIT_CFG = ["-c", "core.autocrlf=false", ...GIT_CORE]
|
||||
const GIT_CFG_QUOTE = [...GIT_CFG, "-c", "core.quotepath=false"]
|
||||
|
||||
interface GitResult {
|
||||
readonly code: ChildProcessSpawner.ExitCode
|
||||
readonly text: string
|
||||
readonly stderr: string
|
||||
}
|
||||
|
||||
export namespace Snapshot {
|
||||
export const Patch = z.object({
|
||||
hash: z.string(),
|
||||
@@ -44,43 +30,47 @@ export namespace Snapshot {
|
||||
})
|
||||
export type FileDiff = z.infer<typeof FileDiff>
|
||||
|
||||
// Promise facade — existing callers use these
|
||||
export function init() {
|
||||
void runPromiseInstance(SnapshotService.use((s) => s.init()))
|
||||
}
|
||||
|
||||
export async function cleanup() {
|
||||
return runPromiseInstance(SnapshotService.use((s) => s.cleanup()))
|
||||
return runPromiseInstance(Service.use((svc) => svc.cleanup()))
|
||||
}
|
||||
|
||||
export async function track() {
|
||||
return runPromiseInstance(SnapshotService.use((s) => s.track()))
|
||||
return runPromiseInstance(Service.use((svc) => svc.track()))
|
||||
}
|
||||
|
||||
export async function patch(hash: string) {
|
||||
return runPromiseInstance(SnapshotService.use((s) => s.patch(hash)))
|
||||
return runPromiseInstance(Service.use((svc) => svc.patch(hash)))
|
||||
}
|
||||
|
||||
export async function restore(snapshot: string) {
|
||||
return runPromiseInstance(SnapshotService.use((s) => s.restore(snapshot)))
|
||||
return runPromiseInstance(Service.use((svc) => svc.restore(snapshot)))
|
||||
}
|
||||
|
||||
export async function revert(patches: Patch[]) {
|
||||
return runPromiseInstance(SnapshotService.use((s) => s.revert(patches)))
|
||||
return runPromiseInstance(Service.use((svc) => svc.revert(patches)))
|
||||
}
|
||||
|
||||
export async function diff(hash: string) {
|
||||
return runPromiseInstance(SnapshotService.use((s) => s.diff(hash)))
|
||||
return runPromiseInstance(Service.use((svc) => svc.diff(hash)))
|
||||
}
|
||||
|
||||
export async function diffFull(from: string, to: string) {
|
||||
return runPromiseInstance(SnapshotService.use((s) => s.diffFull(from, to)))
|
||||
return runPromiseInstance(Service.use((svc) => svc.diffFull(from, to)))
|
||||
}
|
||||
}
|
||||
|
||||
export namespace SnapshotService {
|
||||
export interface Service {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
const log = Log.create({ service: "snapshot" })
|
||||
const prune = "7.days"
|
||||
const core = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"]
|
||||
const cfg = ["-c", "core.autocrlf=false", ...core]
|
||||
const quote = [...cfg, "-c", "core.quotepath=false"]
|
||||
|
||||
interface GitResult {
|
||||
readonly code: ChildProcessSpawner.ExitCode
|
||||
readonly text: string
|
||||
readonly stderr: string
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly cleanup: () => Effect.Effect<void>
|
||||
readonly track: () => Effect.Effect<string | undefined>
|
||||
readonly patch: (hash: string) => Effect.Effect<Snapshot.Patch>
|
||||
@@ -89,99 +79,92 @@ export namespace SnapshotService {
|
||||
readonly diff: (hash: string) => Effect.Effect<string>
|
||||
readonly diffFull: (from: string, to: string) => Effect.Effect<Snapshot.FileDiff[]>
|
||||
}
|
||||
}
|
||||
|
||||
export class SnapshotService extends ServiceMap.Service<SnapshotService, SnapshotService.Service>()(
|
||||
"@opencode/Snapshot",
|
||||
) {
|
||||
static readonly layer = Layer.effect(
|
||||
SnapshotService,
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Snapshot") {}
|
||||
|
||||
export const layer: Layer.Layer<
|
||||
Service,
|
||||
never,
|
||||
InstanceContext | FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner
|
||||
> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const ctx = yield* InstanceContext
|
||||
const fileSystem = yield* FileSystem.FileSystem
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
const { directory, worktree, project } = ctx
|
||||
const isGit = project.vcs === "git"
|
||||
const snapshotGit = path.join(Global.Path.data, "snapshot", project.id)
|
||||
const directory = ctx.directory
|
||||
const worktree = ctx.worktree
|
||||
const project = ctx.project
|
||||
const gitdir = path.join(Global.Path.data, "snapshot", project.id)
|
||||
|
||||
const gitArgs = (cmd: string[]) => ["--git-dir", snapshotGit, "--work-tree", worktree, ...cmd]
|
||||
const args = (cmd: string[]) => ["--git-dir", gitdir, "--work-tree", worktree, ...cmd]
|
||||
|
||||
// Run git with nothrow semantics — always returns a result, never fails
|
||||
const git = (args: string[], opts?: { cwd?: string; env?: Record<string, string> }): Effect.Effect<GitResult> =>
|
||||
Effect.gen(function* () {
|
||||
const command = ChildProcess.make("git", args, {
|
||||
const git = Effect.fnUntraced(
|
||||
function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
|
||||
const proc = ChildProcess.make("git", cmd, {
|
||||
cwd: opts?.cwd,
|
||||
env: opts?.env,
|
||||
extendEnv: true,
|
||||
})
|
||||
const handle = yield* spawner.spawn(command)
|
||||
const handle = yield* spawner.spawn(proc)
|
||||
const [text, stderr] = yield* Effect.all(
|
||||
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const code = yield* handle.exitCode
|
||||
return { code, text, stderr }
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.catch((err) =>
|
||||
Effect.succeed({
|
||||
code: ChildProcessSpawner.ExitCode(1),
|
||||
text: "",
|
||||
stderr: String(err),
|
||||
}),
|
||||
),
|
||||
)
|
||||
return { code, text, stderr } satisfies GitResult
|
||||
},
|
||||
Effect.scoped,
|
||||
Effect.catch((err) =>
|
||||
Effect.succeed({
|
||||
code: ChildProcessSpawner.ExitCode(1),
|
||||
text: "",
|
||||
stderr: String(err),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
// FileSystem helpers — orDie converts PlatformError to defects
|
||||
const exists = (p: string) => fileSystem.exists(p).pipe(Effect.orDie)
|
||||
const mkdir = (p: string) => fileSystem.makeDirectory(p, { recursive: true }).pipe(Effect.orDie)
|
||||
const writeFile = (p: string, content: string) => fileSystem.writeFileString(p, content).pipe(Effect.orDie)
|
||||
const readFile = (p: string) => fileSystem.readFileString(p).pipe(Effect.catch(() => Effect.succeed("")))
|
||||
const removeFile = (p: string) => fileSystem.remove(p).pipe(Effect.catch(() => Effect.void))
|
||||
const exists = (file: string) => fs.exists(file).pipe(Effect.orDie)
|
||||
const mkdir = (dir: string) => fs.makeDirectory(dir, { recursive: true }).pipe(Effect.orDie)
|
||||
const write = (file: string, text: string) => fs.writeFileString(file, text).pipe(Effect.orDie)
|
||||
const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("")))
|
||||
const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void))
|
||||
|
||||
// --- internal Effect helpers ---
|
||||
|
||||
const isEnabled = Effect.gen(function* () {
|
||||
if (!isGit) return false
|
||||
const cfg = yield* Effect.promise(() => Config.get())
|
||||
return cfg.snapshot !== false
|
||||
const enabled = Effect.fnUntraced(function* () {
|
||||
if (project.vcs !== "git") return false
|
||||
return (yield* Effect.promise(() => Config.get())).snapshot !== false
|
||||
})
|
||||
|
||||
const excludesPath = Effect.gen(function* () {
|
||||
const excludes = Effect.fnUntraced(function* () {
|
||||
const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
|
||||
cwd: worktree,
|
||||
})
|
||||
const file = result.text.trim()
|
||||
if (!file) return undefined
|
||||
if (!(yield* exists(file))) return undefined
|
||||
if (!file) return
|
||||
if (!(yield* exists(file))) return
|
||||
return file
|
||||
})
|
||||
|
||||
const syncExclude = Effect.gen(function* () {
|
||||
const file = yield* excludesPath
|
||||
const target = path.join(snapshotGit, "info", "exclude")
|
||||
yield* mkdir(path.join(snapshotGit, "info"))
|
||||
const sync = Effect.fnUntraced(function* () {
|
||||
const file = yield* excludes()
|
||||
const target = path.join(gitdir, "info", "exclude")
|
||||
yield* mkdir(path.join(gitdir, "info"))
|
||||
if (!file) {
|
||||
yield* writeFile(target, "")
|
||||
yield* write(target, "")
|
||||
return
|
||||
}
|
||||
const text = yield* readFile(file)
|
||||
yield* writeFile(target, text)
|
||||
yield* write(target, yield* read(file))
|
||||
})
|
||||
|
||||
const add = Effect.gen(function* () {
|
||||
yield* syncExclude
|
||||
yield* git([...GIT_CFG, ...gitArgs(["add", "."])], { cwd: directory })
|
||||
const add = Effect.fnUntraced(function* () {
|
||||
yield* sync()
|
||||
yield* git([...cfg, ...args(["add", "."])], { cwd: directory })
|
||||
})
|
||||
|
||||
// --- service methods ---
|
||||
|
||||
const cleanup = Effect.fn("SnapshotService.cleanup")(function* () {
|
||||
if (!(yield* isEnabled)) return
|
||||
if (!(yield* exists(snapshotGit))) return
|
||||
const result = yield* git(gitArgs(["gc", `--prune=${PRUNE}`]), {
|
||||
cwd: directory,
|
||||
})
|
||||
const cleanup = Effect.fn("Snapshot.cleanup")(function* () {
|
||||
if (!(yield* enabled())) return
|
||||
if (!(yield* exists(gitdir))) return
|
||||
const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: directory })
|
||||
if (result.code !== 0) {
|
||||
log.warn("cleanup failed", {
|
||||
exitCode: result.code,
|
||||
@@ -189,58 +172,55 @@ export class SnapshotService extends ServiceMap.Service<SnapshotService, Snapsho
|
||||
})
|
||||
return
|
||||
}
|
||||
log.info("cleanup", { prune: PRUNE })
|
||||
log.info("cleanup", { prune })
|
||||
})
|
||||
|
||||
const track = Effect.fn("SnapshotService.track")(function* () {
|
||||
if (!(yield* isEnabled)) return undefined
|
||||
const existed = yield* exists(snapshotGit)
|
||||
yield* mkdir(snapshotGit)
|
||||
const track = Effect.fn("Snapshot.track")(function* () {
|
||||
if (!(yield* enabled())) return
|
||||
const existed = yield* exists(gitdir)
|
||||
yield* mkdir(gitdir)
|
||||
if (!existed) {
|
||||
yield* git(["init"], {
|
||||
env: { GIT_DIR: snapshotGit, GIT_WORK_TREE: worktree },
|
||||
env: { GIT_DIR: gitdir, GIT_WORK_TREE: worktree },
|
||||
})
|
||||
yield* git(["--git-dir", snapshotGit, "config", "core.autocrlf", "false"])
|
||||
yield* git(["--git-dir", snapshotGit, "config", "core.longpaths", "true"])
|
||||
yield* git(["--git-dir", snapshotGit, "config", "core.symlinks", "true"])
|
||||
yield* git(["--git-dir", snapshotGit, "config", "core.fsmonitor", "false"])
|
||||
yield* git(["--git-dir", gitdir, "config", "core.autocrlf", "false"])
|
||||
yield* git(["--git-dir", gitdir, "config", "core.longpaths", "true"])
|
||||
yield* git(["--git-dir", gitdir, "config", "core.symlinks", "true"])
|
||||
yield* git(["--git-dir", gitdir, "config", "core.fsmonitor", "false"])
|
||||
log.info("initialized")
|
||||
}
|
||||
yield* add
|
||||
const result = yield* git(gitArgs(["write-tree"]), { cwd: directory })
|
||||
yield* add()
|
||||
const result = yield* git(args(["write-tree"]), { cwd: directory })
|
||||
const hash = result.text.trim()
|
||||
log.info("tracking", { hash, cwd: directory, git: snapshotGit })
|
||||
log.info("tracking", { hash, cwd: directory, git: gitdir })
|
||||
return hash
|
||||
})
|
||||
|
||||
const patch = Effect.fn("SnapshotService.patch")(function* (hash: string) {
|
||||
yield* add
|
||||
const result = yield* git(
|
||||
[...GIT_CFG_QUOTE, ...gitArgs(["diff", "--no-ext-diff", "--name-only", hash, "--", "."])],
|
||||
{ cwd: directory },
|
||||
)
|
||||
|
||||
const patch = Effect.fn("Snapshot.patch")(function* (hash: string) {
|
||||
yield* add()
|
||||
const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", "--name-only", hash, "--", "."])], {
|
||||
cwd: directory,
|
||||
})
|
||||
if (result.code !== 0) {
|
||||
log.warn("failed to get diff", { hash, exitCode: result.code })
|
||||
return { hash, files: [] } as Snapshot.Patch
|
||||
return { hash, files: [] }
|
||||
}
|
||||
|
||||
return {
|
||||
hash,
|
||||
files: result.text
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((x: string) => x.trim())
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
.map((x: string) => path.join(worktree, x).replaceAll("\\", "/")),
|
||||
} as Snapshot.Patch
|
||||
.map((x) => path.join(worktree, x).replaceAll("\\", "/")),
|
||||
}
|
||||
})
|
||||
|
||||
const restore = Effect.fn("SnapshotService.restore")(function* (snapshot: string) {
|
||||
const restore = Effect.fn("Snapshot.restore")(function* (snapshot: string) {
|
||||
log.info("restore", { commit: snapshot })
|
||||
const result = yield* git([...GIT_CORE, ...gitArgs(["read-tree", snapshot])], { cwd: worktree })
|
||||
const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: worktree })
|
||||
if (result.code === 0) {
|
||||
const checkout = yield* git([...GIT_CORE, ...gitArgs(["checkout-index", "-a", "-f"])], { cwd: worktree })
|
||||
const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], { cwd: worktree })
|
||||
if (checkout.code === 0) return
|
||||
log.error("failed to restore snapshot", {
|
||||
snapshot,
|
||||
@@ -256,38 +236,33 @@ export class SnapshotService extends ServiceMap.Service<SnapshotService, Snapsho
|
||||
})
|
||||
})
|
||||
|
||||
const revert = Effect.fn("SnapshotService.revert")(function* (patches: Snapshot.Patch[]) {
|
||||
const revert = Effect.fn("Snapshot.revert")(function* (patches: Snapshot.Patch[]) {
|
||||
const seen = new Set<string>()
|
||||
for (const item of patches) {
|
||||
for (const file of item.files) {
|
||||
if (seen.has(file)) continue
|
||||
seen.add(file)
|
||||
log.info("reverting", { file, hash: item.hash })
|
||||
const result = yield* git([...GIT_CORE, ...gitArgs(["checkout", item.hash, "--", file])], {
|
||||
cwd: worktree,
|
||||
})
|
||||
const result = yield* git([...core, ...args(["checkout", item.hash, "--", file])], { cwd: worktree })
|
||||
if (result.code !== 0) {
|
||||
const relativePath = path.relative(worktree, file)
|
||||
const checkTree = yield* git([...GIT_CORE, ...gitArgs(["ls-tree", item.hash, "--", relativePath])], {
|
||||
cwd: worktree,
|
||||
})
|
||||
if (checkTree.code === 0 && checkTree.text.trim()) {
|
||||
const rel = path.relative(worktree, file)
|
||||
const tree = yield* git([...core, ...args(["ls-tree", item.hash, "--", rel])], { cwd: worktree })
|
||||
if (tree.code === 0 && tree.text.trim()) {
|
||||
log.info("file existed in snapshot but checkout failed, keeping", { file })
|
||||
} else {
|
||||
log.info("file did not exist in snapshot, deleting", { file })
|
||||
yield* removeFile(file)
|
||||
yield* remove(file)
|
||||
}
|
||||
}
|
||||
seen.add(file)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const diff = Effect.fn("SnapshotService.diff")(function* (hash: string) {
|
||||
yield* add
|
||||
const result = yield* git([...GIT_CFG_QUOTE, ...gitArgs(["diff", "--no-ext-diff", hash, "--", "."])], {
|
||||
const diff = Effect.fn("Snapshot.diff")(function* (hash: string) {
|
||||
yield* add()
|
||||
const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", hash, "--", "."])], {
|
||||
cwd: worktree,
|
||||
})
|
||||
|
||||
if (result.code !== 0) {
|
||||
log.warn("failed to get diff", {
|
||||
hash,
|
||||
@@ -296,19 +271,15 @@ export class SnapshotService extends ServiceMap.Service<SnapshotService, Snapsho
|
||||
})
|
||||
return ""
|
||||
}
|
||||
|
||||
return result.text.trim()
|
||||
})
|
||||
|
||||
const diffFull = Effect.fn("SnapshotService.diffFull")(function* (from: string, to: string) {
|
||||
const diffFull = Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) {
|
||||
const result: Snapshot.FileDiff[] = []
|
||||
const status = new Map<string, "added" | "deleted" | "modified">()
|
||||
|
||||
const statuses = yield* git(
|
||||
[
|
||||
...GIT_CFG_QUOTE,
|
||||
...gitArgs(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."]),
|
||||
],
|
||||
[...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])],
|
||||
{ cwd: directory },
|
||||
)
|
||||
|
||||
@@ -316,64 +287,60 @@ export class SnapshotService extends ServiceMap.Service<SnapshotService, Snapsho
|
||||
if (!line) continue
|
||||
const [code, file] = line.split("\t")
|
||||
if (!code || !file) continue
|
||||
const kind = code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified"
|
||||
status.set(file, kind)
|
||||
status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified")
|
||||
}
|
||||
|
||||
const numstat = yield* git(
|
||||
[...GIT_CFG_QUOTE, ...gitArgs(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
|
||||
{ cwd: directory },
|
||||
[...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
|
||||
{
|
||||
cwd: directory,
|
||||
},
|
||||
)
|
||||
|
||||
for (const line of numstat.text.trim().split("\n")) {
|
||||
if (!line) continue
|
||||
const [additions, deletions, file] = line.split("\t")
|
||||
const isBinaryFile = additions === "-" && deletions === "-"
|
||||
const [before, after] = isBinaryFile
|
||||
const [adds, dels, file] = line.split("\t")
|
||||
if (!file) continue
|
||||
const binary = adds === "-" && dels === "-"
|
||||
const [before, after] = binary
|
||||
? ["", ""]
|
||||
: yield* Effect.all(
|
||||
[
|
||||
git([...GIT_CFG, ...gitArgs(["show", `${from}:${file}`])]).pipe(Effect.map((r) => r.text)),
|
||||
git([...GIT_CFG, ...gitArgs(["show", `${to}:${file}`])]).pipe(Effect.map((r) => r.text)),
|
||||
git([...cfg, ...args(["show", `${from}:${file}`])]).pipe(Effect.map((item) => item.text)),
|
||||
git([...cfg, ...args(["show", `${to}:${file}`])]).pipe(Effect.map((item) => item.text)),
|
||||
],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const added = isBinaryFile ? 0 : parseInt(additions!)
|
||||
const deleted = isBinaryFile ? 0 : parseInt(deletions!)
|
||||
const additions = binary ? 0 : parseInt(adds)
|
||||
const deletions = binary ? 0 : parseInt(dels)
|
||||
result.push({
|
||||
file: file!,
|
||||
file,
|
||||
before,
|
||||
after,
|
||||
additions: Number.isFinite(added) ? added : 0,
|
||||
deletions: Number.isFinite(deleted) ? deleted : 0,
|
||||
status: status.get(file!) ?? "modified",
|
||||
additions: Number.isFinite(additions) ? additions : 0,
|
||||
deletions: Number.isFinite(deletions) ? deletions : 0,
|
||||
status: status.get(file) ?? "modified",
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
// Start hourly cleanup fiber — scoped to instance lifetime
|
||||
yield* cleanup().pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
log.error("cleanup loop failed", { cause: Cause.pretty(cause) })
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.repeat(Schedule.spaced(Duration.hours(1))),
|
||||
Effect.delay(Duration.minutes(1)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
return SnapshotService.of({
|
||||
init: Effect.fn("SnapshotService.init")(function* () {}),
|
||||
cleanup,
|
||||
track,
|
||||
patch,
|
||||
restore,
|
||||
revert,
|
||||
diff,
|
||||
diffFull,
|
||||
})
|
||||
return Service.of({ cleanup, track, patch, restore, revert, diff, diffFull })
|
||||
}),
|
||||
).pipe(
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(NodeChildProcessSpawner.layer),
|
||||
Layer.provide(NodeFileSystem.layer),
|
||||
Layer.provide(NodePath.layer),
|
||||
|
||||
8
packages/opencode/src/storage/db.bun.ts
Normal file
8
packages/opencode/src/storage/db.bun.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Database } from "bun:sqlite"
|
||||
import { drizzle } from "drizzle-orm/bun-sqlite"
|
||||
|
||||
export function init(path: string) {
|
||||
const sqlite = new Database(path, { create: true })
|
||||
const db = drizzle({ client: sqlite })
|
||||
return db
|
||||
}
|
||||
8
packages/opencode/src/storage/db.node.ts
Normal file
8
packages/opencode/src/storage/db.node.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { DatabaseSync } from "node:sqlite"
|
||||
import { drizzle } from "drizzle-orm/node-sqlite"
|
||||
|
||||
export function init(path: string) {
|
||||
const sqlite = new DatabaseSync(path)
|
||||
const db = drizzle({ client: sqlite })
|
||||
return db
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Database as BunDatabase } from "bun:sqlite"
|
||||
import { drizzle, type SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"
|
||||
import { type SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"
|
||||
import { migrate } from "drizzle-orm/bun-sqlite/migrator"
|
||||
import { type SQLiteTransaction } from "drizzle-orm/sqlite-core"
|
||||
export * from "drizzle-orm"
|
||||
@@ -11,10 +10,10 @@ import { NamedError } from "@opencode-ai/util/error"
|
||||
import z from "zod"
|
||||
import path from "path"
|
||||
import { readFileSync, readdirSync, existsSync } from "fs"
|
||||
import * as schema from "./schema"
|
||||
import { Installation } from "../installation"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { iife } from "@/util/iife"
|
||||
import { init } from "#db"
|
||||
|
||||
declare const OPENCODE_MIGRATIONS: { sql: string; timestamp: number; name: string }[] | undefined
|
||||
|
||||
@@ -36,17 +35,12 @@ export namespace Database {
|
||||
return path.join(Global.Path.data, `opencode-${safe}.db`)
|
||||
})
|
||||
|
||||
type Schema = typeof schema
|
||||
export type Transaction = SQLiteTransaction<"sync", void, Schema>
|
||||
export type Transaction = SQLiteTransaction<"sync", void>
|
||||
|
||||
type Client = SQLiteBunDatabase
|
||||
|
||||
type Journal = { sql: string; timestamp: number; name: string }[]
|
||||
|
||||
const state = {
|
||||
sqlite: undefined as BunDatabase | undefined,
|
||||
}
|
||||
|
||||
function time(tag: string) {
|
||||
const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(tag)
|
||||
if (!match) return 0
|
||||
@@ -83,17 +77,14 @@ export namespace Database {
|
||||
export const Client = lazy(() => {
|
||||
log.info("opening database", { path: Path })
|
||||
|
||||
const sqlite = new BunDatabase(Path, { create: true })
|
||||
state.sqlite = sqlite
|
||||
const db = init(Path)
|
||||
|
||||
sqlite.run("PRAGMA journal_mode = WAL")
|
||||
sqlite.run("PRAGMA synchronous = NORMAL")
|
||||
sqlite.run("PRAGMA busy_timeout = 5000")
|
||||
sqlite.run("PRAGMA cache_size = -64000")
|
||||
sqlite.run("PRAGMA foreign_keys = ON")
|
||||
sqlite.run("PRAGMA wal_checkpoint(PASSIVE)")
|
||||
|
||||
const db = drizzle({ client: sqlite })
|
||||
db.run("PRAGMA journal_mode = WAL")
|
||||
db.run("PRAGMA synchronous = NORMAL")
|
||||
db.run("PRAGMA busy_timeout = 5000")
|
||||
db.run("PRAGMA cache_size = -64000")
|
||||
db.run("PRAGMA foreign_keys = ON")
|
||||
db.run("PRAGMA wal_checkpoint(PASSIVE)")
|
||||
|
||||
// Apply schema migrations
|
||||
const entries =
|
||||
@@ -117,14 +108,11 @@ export namespace Database {
|
||||
})
|
||||
|
||||
export function close() {
|
||||
const sqlite = state.sqlite
|
||||
if (!sqlite) return
|
||||
sqlite.close()
|
||||
state.sqlite = undefined
|
||||
Client().$client.close()
|
||||
Client.reset()
|
||||
}
|
||||
|
||||
export type TxOrDb = SQLiteTransaction<"sync", void, any, any> | Client
|
||||
export type TxOrDb = Transaction | Client
|
||||
|
||||
const ctx = Context.create<{
|
||||
tx: TxOrDb
|
||||
|
||||
@@ -46,7 +46,7 @@ export namespace ToolRegistry {
|
||||
if (matches.length) await Config.waitForDependencies()
|
||||
for (const match of matches) {
|
||||
const namespace = path.basename(match, path.extname(match))
|
||||
const mod = await import(pathToFileURL(match).href)
|
||||
const mod = await import(process.platform === "win32" ? match : pathToFileURL(match).href)
|
||||
for (const [id, def] of Object.entries<ToolDefinition>(mod)) {
|
||||
custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def))
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { Cause, Duration, Effect, FileSystem, Layer, Schedule, ServiceMap } from "effect"
|
||||
import path from "path"
|
||||
import type { Agent } from "../agent/agent"
|
||||
import { PermissionEffect } from "../permission/service"
|
||||
import { PermissionNext } from "../permission"
|
||||
import { Identifier } from "../id/id"
|
||||
import { Log } from "../util/log"
|
||||
import { ToolID } from "./schema"
|
||||
@@ -27,10 +27,10 @@ export namespace TruncateEffect {
|
||||
|
||||
function hasTaskTool(agent?: Agent.Info) {
|
||||
if (!agent?.permission) return false
|
||||
return PermissionEffect.evaluate("task", "*", agent.permission).action !== "deny"
|
||||
return PermissionNext.evaluate("task", "*", agent.permission).action !== "deny"
|
||||
}
|
||||
|
||||
export interface Api {
|
||||
export interface Interface {
|
||||
readonly cleanup: () => Effect.Effect<void>
|
||||
/**
|
||||
* Returns output unchanged when it fits within the limits, otherwise writes the full text
|
||||
@@ -39,14 +39,14 @@ export namespace TruncateEffect {
|
||||
readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect<Result>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Api>()("@opencode/Truncate") {}
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Truncate") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
|
||||
const cleanup = Effect.fn("TruncateEffect.cleanup")(function* () {
|
||||
const cleanup = Effect.fn("Truncate.cleanup")(function* () {
|
||||
const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - Duration.toMillis(RETENTION)))
|
||||
const entries = yield* fs.readDirectory(TRUNCATION_DIR).pipe(
|
||||
Effect.map((all) => all.filter((name) => name.startsWith("tool_"))),
|
||||
@@ -58,11 +58,7 @@ export namespace TruncateEffect {
|
||||
}
|
||||
})
|
||||
|
||||
const output = Effect.fn("TruncateEffect.output")(function* (
|
||||
text: string,
|
||||
options: Options = {},
|
||||
agent?: Agent.Info,
|
||||
) {
|
||||
const output = Effect.fn("Truncate.output")(function* (text: string, options: Options = {}, agent?: Agent.Info) {
|
||||
const maxLines = options.maxLines ?? MAX_LINES
|
||||
const maxBytes = options.maxBytes ?? MAX_BYTES
|
||||
const direction = options.direction ?? "head"
|
||||
|
||||
@@ -15,6 +15,7 @@ export namespace Process {
|
||||
abort?: AbortSignal
|
||||
kill?: NodeJS.Signals | number
|
||||
timeout?: number
|
||||
shell?: string
|
||||
}
|
||||
|
||||
export interface RunOptions extends Omit<Options, "stdout" | "stderr"> {
|
||||
@@ -60,6 +61,7 @@ export namespace Process {
|
||||
|
||||
const proc = launch(cmd[0], cmd.slice(1), {
|
||||
cwd: opts.cwd,
|
||||
shell: opts.shell,
|
||||
env: opts.env === null ? {} : opts.env ? { ...process.env, ...opts.env } : undefined,
|
||||
stdio: [opts.stdin ?? "ignore", opts.stdout ?? "ignore", opts.stderr ?? "ignore"],
|
||||
shell: opts.shell,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user