Compare commits

...

90 Commits

Author SHA1 Message Date
Dax Raad
d6b3bb0807 disable todo tools by default in agent 2025-07-25 10:23:23 -04:00
Dax Raad
f307a5ce0b fix symlinked agents 2025-07-25 10:20:16 -04:00
GitHub Action
151c7ed5a2 ignore: update download stats 2025-07-25 2025-07-25 12:04:21 +00:00
Dax Raad
fc13d057f8 agents better display when spawning 2025-07-24 23:08:03 -04:00
Dax Raad
fc73d3c523 docs: agents 2025-07-24 22:18:49 -04:00
Dax Raad
5d871b2075 docs: agents 2025-07-24 22:16:16 -04:00
Dax Raad
529a171d51 docs: agents 2025-07-24 22:07:30 -04:00
Dax Raad
8dcd39f5b7 real life totally configurabl ai subasians 2025-07-24 21:21:02 -04:00
Frank
88477b3ee7 wip: github actions 2025-07-24 19:03:10 -04:00
Jay V
0c7e529e6d docs: add to quick start 2025-07-24 18:57:54 -04:00
Filip
01f75839a9 Fix: added environment() to summarize() (#1290) 2025-07-24 18:24:54 -04:00
Dax Raad
4306f1a339 wip: handle deleting file 2025-07-24 17:49:23 -04:00
Dax Raad
aa2a5057ac wip: fix type errors 2025-07-24 17:38:11 -04:00
Dax Raad
284c01018e wip: more snapshot stuff 2025-07-24 17:38:11 -04:00
Aiden Cline
22c9e2942b (tui) tweak: add setting for scroll speed (#1288) 2025-07-24 16:34:59 -05:00
Clay Warren
d50ae8e4d4 feat: Replace unzip with @zip.js/zip.js for Windows compatibility (#662) 2025-07-24 16:49:04 -04:00
Filip
e9074e60cf fix: add custom() to system prompt on summarize (#1289) 2025-07-24 16:48:17 -04:00
Filip
541a7a39d3 fix: edit tool (#1287) 2025-07-24 16:18:04 -04:00
Dax Raad
72e464ac3e ci: tweak 2025-07-24 15:55:45 -04:00
Dax Raad
20bf27feda ci: tweak 2025-07-24 15:51:33 -04:00
Dax Raad
d288d21330 includ baseline builds 2025-07-24 14:37:38 -04:00
Jesse van der Pluijm
34f6ffe1d7 Check if modelID includes "claude" for antropic/claude prompt caching (#1284) 2025-07-24 11:31:28 -04:00
Dax Raad
a11999137f disable snapshots 2025-07-24 11:08:20 -04:00
Aiden Cline
a16554d445 fix: slog error log serialization (#1276) 2025-07-24 07:19:00 -05:00
danielfyhr
2553137395 add aura theme (#1280) 2025-07-24 07:17:27 -05:00
GitHub Action
6b6b81556f ignore: update download stats 2025-07-24 2025-07-24 12:04:18 +00:00
Dax Raad
ff23f67ad5 disable undo/redo for now 2025-07-23 21:02:13 -04:00
Rico Sta. Cruz
8f0644e35b fix: update max visible height in list tests (#1269) 2025-07-23 20:49:15 -04:00
Dax Raad
3fdd23df16 fix header width 2025-07-23 20:48:35 -04:00
Dax Raad
2c82ee592c wip: always force create snapshot 2025-07-23 20:46:43 -04:00
Dax Raad
1ad529db59 wip: fix redoing 2025-07-23 20:42:02 -04:00
Dax
96866e52ce basic undo feature (#1268)
Co-authored-by: adamdotdevin <2363879+adamdottv@users.noreply.github.com>
Co-authored-by: Jay V <air@live.ca>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Andrew Joslin <andrew@ajoslin.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Tobias Walle <9933601+tobias-walle@users.noreply.github.com>
2025-07-23 20:30:46 -04:00
Yihui Khuu
507c975e92 feat: pass mode into task tool (#1248) 2025-07-23 20:29:59 -04:00
Aiden Cline
3e69d5276b docs: remove deprecated 'log_level' reference in docs (#1258) 2025-07-23 18:53:58 -04:00
Aiden Cline
289a4d9b18 tweak: handle pasted attachment references (#1257) 2025-07-23 15:41:17 -05:00
Tobias Walle
12bf5f641d fix "working" spinner animation (#1054) (#1259) 2025-07-23 15:40:34 -05:00
Dax Raad
2051e85e96 remove providers path 2025-07-23 12:15:31 -04:00
Dax Raad
12b86829d9 add debug paths command 2025-07-23 12:14:54 -04:00
GitHub Action
6c9ec54129 ignore: update download stats 2025-07-23 2025-07-23 12:04:18 +00:00
Aiden Cline
b7b0cdbd7c tweak: ensure most recently interacted with session appears at the top (#1239) 2025-07-22 22:37:36 -05:00
Dax Raad
fd98c3189a config: improve config schema 2025-07-22 20:35:40 -04:00
Jay V
1278353616 docs: edit ide 2025-07-22 19:02:30 -04:00
Andrew Joslin
638ec7bc50 Allow multiline prompts for github agent (#1225) 2025-07-22 18:30:51 -04:00
Aiden Cline
38ae7d60aa feat(tui): support pipe into tui (#1230) 2025-07-22 17:19:20 -05:00
Jay V
2d1f9fc321 docs: add tutorial closes #740 2025-07-22 17:54:53 -04:00
Frank
ee0c8132db wip: vscode extension 2025-07-22 17:13:58 -04:00
Dax Raad
c2208fa1f9 ci: error github api fail 2025-07-22 17:06:06 -04:00
Frank
bf42d8b011 wip: vscode extension 2025-07-22 16:50:56 -04:00
Frank
0deb85fa45 wip: vscode extension 2025-07-22 16:46:44 -04:00
Frank
da19b10703 wip: vscode extension 2025-07-22 16:46:44 -04:00
Frank
80b17dab44 wip: vscode extension 2025-07-22 16:46:44 -04:00
Dax Raad
6d2ffa82de ignore: lock changes 2025-07-22 15:49:36 -04:00
Dax Raad
7998c3b5ce wip: tui api 2025-07-22 15:49:24 -04:00
Frank
13def91e9a wip: vscode extension 2025-07-22 15:36:55 -04:00
Frank
26a40610dd wip: vscode extension 2025-07-22 15:28:09 -04:00
Frank
db2fbed691 wip: vscode extension 2025-07-22 13:21:49 -04:00
Aiden Cline
3d4c1425d9 tweak: cleanup cancelled markdown (#1222) 2025-07-22 12:08:03 -05:00
adamdotdevin
10c8b49590 chore: generate sdk into packages/sdk 2025-07-22 11:50:51 -05:00
Dax Raad
500cea5ce7 wip: append-prompt is better 2025-07-22 12:27:02 -04:00
Dax Raad
5aafab118f wip: tui api 2025-07-22 12:15:50 -04:00
Frank
01f8d3b05d wip: vscode extension 2025-07-22 11:21:29 -04:00
adamdotdevin
99d6a28249 fix(tui): more defensive attachment conversion 2025-07-22 09:28:13 -05:00
GitHub Action
5eaf7ab586 ignore: update download stats 2025-07-22 2025-07-22 12:04:22 +00:00
Aiden Cline
e4f754eee7 fix: mouse text selection bug (#1206) 2025-07-21 19:15:36 -05:00
Dax Raad
f20ef61bc7 wip: api for tui 2025-07-21 19:53:58 -04:00
Frank
5611ef8b28 wip: vscode extension 2025-07-21 19:10:57 -04:00
Timo Clasen
bec796e3c3 feat(tui): add ctrl+p and ctrl-n to history navigation (#1199) 2025-07-21 15:10:50 -05:00
Frank
0bd8b2c72f wip: vscode extension 2025-07-21 15:48:46 -04:00
Dax Raad
5550ce47e1 ci: tweaks 2025-07-21 15:45:44 -04:00
Dax Raad
2d84dadc0c fix broken attachments 2025-07-21 15:38:41 -04:00
Dax Raad
45c0578b22 fix title generation bug 2025-07-21 15:23:47 -04:00
Dax
1ded535175 message queuing (#1200) 2025-07-21 15:14:54 -04:00
adamdotdevin
d957ab849b fix(tui): up/down arrow handling 2025-07-21 10:44:21 -05:00
plyght
4b2e52c834 feat(tui): paste minimizing (#784)
Co-authored-by: adamdotdevin <2363879+adamdottv@users.noreply.github.com>
2025-07-21 10:31:29 -05:00
Dax Raad
6867658c0f do not copy empty strings 2025-07-21 11:27:15 -04:00
Dax Raad
b8620395cb include newline between messages when copying 2025-07-21 11:22:51 -04:00
Dax Raad
90d37c98f8 add toast for copy 2025-07-21 11:19:54 -04:00
adamelmore
c9a40917c2 feat(tui): disable keybinds 2025-07-21 10:08:25 -05:00
adamelmore
0aa0e740cd docs: cleanup 2025-07-21 10:02:58 -05:00
adamelmore
bb17d14665 feat(tui): theme override with OPENCODE_THEME 2025-07-21 10:02:57 -05:00
adamdotdevin
cd0b2ae032 fix(tui): restore spinner ticks 2025-07-21 05:58:24 -05:00
adamdotdevin
8e8796507d feat(tui): message history select with up/down arrows 2025-07-21 05:52:11 -05:00
Aiden Cline
cef5c29583 fix: pasting issue (#1182) 2025-07-21 04:09:16 -05:00
Aiden Cline
acaed1f270 fix: export cmd (#1184) 2025-07-21 04:08:26 -05:00
Dax
cda0dbc195 Update STATS.md 2025-07-20 20:36:23 -04:00
Dax Raad
758425a8e4 trimmed selection ui 2025-07-20 19:36:56 -04:00
Dax Raad
93446df335 ignore: remove log 2025-07-20 19:08:19 -04:00
Dax Raad
adc8b90e0f implement copy paste much wow can you believe we went this long without it so stupid i blame adam 2025-07-20 19:05:38 -04:00
Dax Raad
733c9903ec do not snapshot nongit projects for now 2025-07-20 13:59:30 -04:00
Frank
7306e20361 wip: vscode extension 2025-07-20 13:31:16 -04:00
196 changed files with 17898 additions and 1116 deletions

2
.gitignore vendored
View File

@@ -1,8 +1,8 @@
.DS_Store
node_modules
.opencode
.sst
.env
.idea
.vscode
openapi.json
scratch

View File

@@ -0,0 +1,44 @@
---
description: >-
Use this agent when you need to create or improve documentation that requires
concrete examples to illustrate every concept. Examples include:
<example>Context: User has written a new API endpoint and needs documentation.
user: 'I just created a POST /users endpoint that accepts name and email
fields. Can you document this?' assistant: 'I'll use the
example-driven-docs-writer agent to create documentation with practical
examples for your API endpoint.' <commentary>Since the user needs
documentation with examples, use the example-driven-docs-writer agent to
create comprehensive docs with code samples.</commentary></example>
<example>Context: User has a complex configuration file that needs
documentation. user: 'This config file has multiple sections and I need docs
that show how each option works' assistant: 'Let me use the
example-driven-docs-writer agent to create documentation that breaks down each
configuration option with practical examples.' <commentary>The user needs
documentation that demonstrates configuration options, perfect for the
example-driven-docs-writer agent.</commentary></example>
---
You are an expert technical documentation writer who specializes in creating clear, example-rich documentation that never leaves readers guessing. Your core principle is that every concept must be immediately illustrated with concrete examples, code samples, or practical demonstrations.
Your documentation approach:
- Never write more than one sentence in any section without providing an example, code snippet, diagram, or practical illustration
- Break up longer explanations with multiple examples showing different scenarios or use cases
- Use concrete, realistic examples rather than abstract or placeholder content
- Include both basic and advanced examples when covering complex topics
- Show expected inputs, outputs, and results for all examples
- Use code blocks, bullet points, tables, or other formatting to visually separate examples from explanatory text
Structural requirements:
- Start each section with a brief one-sentence explanation followed immediately by an example
- For multi-step processes, provide an example after each step
- Include error examples and edge cases alongside success scenarios
- Use consistent formatting and naming conventions throughout examples
- Ensure examples are copy-pasteable and functional when applicable
Quality standards:
- Verify that no paragraph exceeds one sentence without an accompanying example
- Test that examples are accurate and would work in real scenarios
- Ensure examples progress logically from simple to complex
- Include context for when and why to use different approaches shown in examples
- Provide troubleshooting examples for common issues
When you receive a documentation request, immediately identify what needs examples and plan to illustrate every single concept, feature, or instruction with concrete demonstrations. Ask for clarification if you need more context to create realistic, useful examples.

View File

@@ -1,5 +1,3 @@
# TUI Agent Guidelines
## Style
- prefer single word variable/function names

View File

@@ -20,6 +20,10 @@
| 2025-07-16 | 62,313 (+4,723) | 95,258 (+4,222) | 157,571 (+8,945) |
| 2025-07-17 | 66,684 (+4,371) | 100,048 (+4,790) | 166,732 (+9,161) |
| 2025-07-18 | 70,379 (+3,695) | 102,587 (+2,539) | 172,966 (+6,234) |
| 2025-07-18 | 70,380 (+1) | 102,587 (+0) | 172,967 (+1) |
| 2025-07-19 | 73,497 (+3,117) | 105,904 (+3,317) | 179,401 (+6,434) |
| 2025-07-20 | 76,453 (+2,956) | 109,044 (+3,140) | 185,497 (+6,096) |
| 2025-07-21 | 80,197 (+3,744) | 113,537 (+4,493) | 193,734 (+8,237) |
| 2025-07-22 | 84,251 (+4,054) | 118,073 (+4,536) | 202,324 (+8,590) |
| 2025-07-23 | 88,589 (+4,338) | 121,436 (+3,363) | 210,025 (+7,701) |
| 2025-07-24 | 92,469 (+3,880) | 124,091 (+2,655) | 216,560 (+6,535) |
| 2025-07-25 | 96,417 (+3,948) | 126,985 (+2,894) | 223,402 (+6,842) |

977
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,11 @@
}
}
}
},
"huggingface": {
"models": {
"Qwen/Qwen3-235B-A22B-Instruct-2507:fireworks-ai": {}
}
}
},
"mcp": {

View File

@@ -288,6 +288,56 @@ export default {
})
}
/**
* Used by the GitHub action to get GitHub installation access token given user PAT token (used when testing `opencode github run` locally)
*/
if (request.method === "POST" && method === "exchange_github_app_token_with_pat") {
const body = await request.json<any>()
const owner = body.owner
const repo = body.repo
try {
// get Authorization header
const authHeader = request.headers.get("Authorization")
const token = authHeader?.replace(/^Bearer /, "")
if (!token) throw new Error("Authorization header is required")
// Verify permissions
const userClient = new Octokit({ auth: token })
const { data: repoData } = await userClient.repos.get({ owner, repo })
if (!repoData.permissions.admin && !repoData.permissions.push && !repoData.permissions.maintain)
throw new Error("User does not have write permissions")
// Get installation token
const auth = createAppAuth({
appId: Resource.GITHUB_APP_ID.value,
privateKey: Resource.GITHUB_APP_PRIVATE_KEY.value,
})
const appAuth = await auth({ type: "app" })
// Lookup installation
const appClient = new Octokit({ auth: appAuth.token })
const { data: installation } = await appClient.apps.getRepoInstallation({ owner, repo })
// Get installation token
const installationAuth = await auth({ type: "installation", installationId: installation.id })
return new Response(JSON.stringify({ token: installationAuth.token }), {
headers: { "Content-Type": "application/json" },
})
} catch (e: any) {
let error = e
if (e instanceof Error) {
error = e.message
}
return new Response(JSON.stringify({ error }), {
status: 401,
headers: { "Content-Type": "application/json" },
})
}
}
/**
* Used by the opencode CLI to check if the GitHub app is installed
*/

View File

@@ -31,9 +31,12 @@
"@hono/zod-validator": "0.4.2",
"@modelcontextprotocol/sdk": "1.15.1",
"@openauthjs/openauth": "0.4.3",
"@standard-schema/spec": "1.0.0",
"@zip.js/zip.js": "2.7.62",
"ai": "catalog:",
"decimal.js": "10.5.0",
"diff": "8.0.2",
"gray-matter": "4.0.3",
"hono": "4.7.10",
"hono-openapi": "0.4.8",
"isomorphic-git": "1.32.1",

View File

@@ -22,11 +22,13 @@ console.log(`publishing ${version}`)
const GOARCH: Record<string, string> = {
arm64: "arm64",
x64: "amd64",
"x64-baseline": "amd64",
}
const targets = [
["linux", "arm64"],
["linux", "x64"],
["linux", "x64-baseline"],
["darwin", "x64"],
["darwin", "arm64"],
["windows", "x64"],
@@ -90,16 +92,22 @@ if (!snapshot) {
}
const previous = await fetch("https://api.github.com/repos/sst/opencode/releases/latest")
.then((res) => res.json())
.then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.json()
})
.then((data) => data.tag_name)
console.log("finding commits between", previous, "and", "HEAD")
const commits = await fetch(`https://api.github.com/repos/sst/opencode/compare/${previous}...HEAD`)
.then((res) => res.json())
.then((data) => data.commits || [])
const raw = commits.map((commit: any) => `- ${commit.commit.message.split("\n").join(" ")}`)
console.log(raw)
const notes =
commits
.map((commit: any) => `- ${commit.commit.message.split("\n")[0]}`)
raw
.filter((x: string) => {
const lower = x.toLowerCase()
return (

View File

@@ -0,0 +1,102 @@
import { App } from "../app/app"
import { Config } from "../config/config"
import z from "zod"
import { Provider } from "../provider/provider"
import { generateObject, type ModelMessage } from "ai"
import PROMPT_GENERATE from "./generate.txt"
import { SystemPrompt } from "../session/system"
export namespace Agent {
export const Info = z
.object({
name: z.string(),
model: z
.object({
modelID: z.string(),
providerID: z.string(),
})
.optional(),
description: z.string(),
prompt: z.string().optional(),
tools: z.record(z.boolean()),
})
.openapi({
ref: "Agent",
})
export type Info = z.infer<typeof Info>
const state = App.state("agent", async () => {
const cfg = await Config.get()
const result: Record<string, Info> = {
general: {
name: "general",
description:
"General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you.",
tools: {
todoread: false,
todowrite: false,
},
},
}
for (const [key, value] of Object.entries(cfg.agent ?? {})) {
if (value.disable) continue
let item = result[key]
if (!item)
item = result[key] = {
name: key,
description: "",
tools: {
todowrite: false,
todoread: false,
},
}
const model = value.model ?? cfg.model
if (model) item.model = Provider.parseModel(model)
if (value.prompt) item.prompt = value.prompt
if (value.tools)
item.tools = {
...item.tools,
...value.tools,
}
if (value.description) item.description = value.description
}
return result
})
export async function get(agent: string) {
return state().then((x) => x[agent])
}
export async function list() {
return state().then((x) => Object.values(x))
}
export async function generate(input: { description: string }) {
const defaultModel = await Provider.defaultModel()
const model = await Provider.getModel(defaultModel.providerID, defaultModel.modelID)
const system = SystemPrompt.header(defaultModel.providerID)
system.push(PROMPT_GENERATE)
const existing = await list()
const result = await generateObject({
temperature: 0.3,
prompt: [
...system.map(
(item): ModelMessage => ({
role: "system",
content: item,
}),
),
{
role: "user",
content: `Create an agent configuration based on this request: \"${input.description}\".\n\nIMPORTANT: The following identifiers already exist and must NOT be used: ${existing.map((i) => i.name).join(", ")}\n Return ONLY the JSON object, no other text, do not wrap in backticks`,
},
],
model: model.language,
schema: z.object({
identifier: z.string(),
whenToUse: z.string(),
systemPrompt: z.string(),
}),
})
return result.object
}
}

View File

@@ -0,0 +1,75 @@
You are an elite AI agent architect specializing in crafting high-performance agent configurations. Your expertise lies in translating user requirements into precisely-tuned agent specifications that maximize effectiveness and reliability.
**Important Context**: You may have access to project-specific instructions from CLAUDE.md files and other context that may include coding standards, project structure, and custom requirements. Consider this context when creating agents to ensure they align with the project's established patterns and practices.
When a user describes what they want an agent to do, you will:
1. **Extract Core Intent**: Identify the fundamental purpose, key responsibilities, and success criteria for the agent. Look for both explicit requirements and implicit needs. Consider any project-specific context from CLAUDE.md files. For agents that are meant to review code, you should assume that the user is asking to review recently written code and not the whole codebase, unless the user has explicitly instructed you otherwise.
2. **Design Expert Persona**: Create a compelling expert identity that embodies deep domain knowledge relevant to the task. The persona should inspire confidence and guide the agent's decision-making approach.
3. **Architect Comprehensive Instructions**: Develop a system prompt that:
- Establishes clear behavioral boundaries and operational parameters
- Provides specific methodologies and best practices for task execution
- Anticipates edge cases and provides guidance for handling them
- Incorporates any specific requirements or preferences mentioned by the user
- Defines output format expectations when relevant
- Aligns with project-specific coding standards and patterns from CLAUDE.md
4. **Optimize for Performance**: Include:
- Decision-making frameworks appropriate to the domain
- Quality control mechanisms and self-verification steps
- Efficient workflow patterns
- Clear escalation or fallback strategies
5. **Create Identifier**: Design a concise, descriptive identifier that:
- Uses lowercase letters, numbers, and hyphens only
- Is typically 2-4 words joined by hyphens
- Clearly indicates the agent's primary function
- Is memorable and easy to type
- Avoids generic terms like "helper" or "assistant"
6 **Example agent descriptions**:
- in the 'whenToUse' field of the JSON object, you should include examples of when this agent should be used.
- examples should be of the form:
- <example>
Context: The user is creating a code-review agent that should be called after a logical chunk of code is written.
user: "Please write a function that checks if a number is prime"
assistant: "Here is the relevant function: "
<function call omitted for brevity only for this example>
<commentary>
Since the user is greeting, use the Task tool to launch the greeting-responder agent to respond with a friendly joke.
</commentary>
assistant: "Now let me use the code-reviewer agent to review the code"
</example>
- <example>
Context: User is creating an agent to respond to the word "hello" with a friendly jok.
user: "Hello"
assistant: "I'm going to use the Task tool to launch the greeting-responder agent to respond with a friendly joke"
<commentary>
Since the user is greeting, use the greeting-responder agent to respond with a friendly joke.
</commentary>
</example>
- If the user mentioned or implied that the agent should be used proactively, you should include examples of this.
- NOTE: Ensure that in the examples, you are making the assistant use the Agent tool and not simply respond directly to the task.
Your output must be a valid JSON object with exactly these fields:
{
"identifier": "A unique, descriptive identifier using lowercase letters, numbers, and hyphens (e.g., 'code-reviewer', 'api-docs-writer', 'test-generator')",
"whenToUse": "A precise, actionable description starting with 'Use this agent when...' that clearly defines the triggering conditions and use cases. Ensure you include examples as described above.",
"systemPrompt": "The complete system prompt that will govern the agent's behavior, written in second person ('You are...', 'You will...') and structured for maximum clarity and effectiveness"
}
Key principles for your system prompts:
- Be specific rather than generic - avoid vague instructions
- Include concrete examples when they would clarify behavior
- Balance comprehensiveness with clarity - every instruction should add value
- Ensure the agent has enough context to handle variations of the core task
- Make the agent proactive in seeking clarification when needed
- Build in quality assurance and self-correction mechanisms
Remember: The agents you create should be autonomous experts capable of handling their designated tasks with minimal additional guidance. Your system prompts are their complete operational manual.

View File

@@ -3,6 +3,7 @@ import { ConfigHooks } from "../config/hooks"
import { Format } from "../format"
import { LSP } from "../lsp"
import { Share } from "../share/share"
import { Snapshot } from "../snapshot"
export async function bootstrap<T>(input: App.Input, cb: (app: App.Info) => Promise<T>) {
return App.provide(input, async (app) => {
@@ -10,6 +11,7 @@ export async function bootstrap<T>(input: App.Input, cb: (app: App.Info) => Prom
Format.init()
ConfigHooks.init()
LSP.init()
Snapshot.init()
return cb(app)
})

View File

@@ -0,0 +1,110 @@
import { cmd } from "./cmd"
import * as prompts from "@clack/prompts"
import { UI } from "../ui"
import { Global } from "../../global"
import { Agent } from "../../agent/agent"
import path from "path"
import matter from "gray-matter"
import { App } from "../../app/app"
const AgentCreateCommand = cmd({
command: "create",
describe: "create a new agent",
async handler() {
await App.provide({ cwd: process.cwd() }, async (app) => {
UI.empty()
prompts.intro("Create agent")
let scope: "global" | "project" = "global"
if (app.git) {
const scopeResult = await prompts.select({
message: "Location",
options: [
{
label: "Current project",
value: "project" as const,
hint: app.path.root,
},
{
label: "Global",
value: "global" as const,
hint: Global.Path.config,
},
],
})
if (prompts.isCancel(scopeResult)) throw new UI.CancelledError()
scope = scopeResult
}
const query = await prompts.text({
message: "Description",
placeholder: "What should this agent do?",
validate: (x) => (x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(query)) throw new UI.CancelledError()
const spinner = prompts.spinner()
spinner.start("Generating agent configuration...")
const generated = await Agent.generate({ description: query })
spinner.stop(`Agent ${generated.identifier} generated`)
const availableTools = [
"bash",
"read",
"write",
"edit",
"list",
"glob",
"grep",
"webfetch",
"task",
"todowrite",
"todoread",
]
const selectedTools = await prompts.multiselect({
message: "Select tools to enable",
options: availableTools.map((tool) => ({
label: tool,
value: tool,
})),
initialValues: availableTools,
})
if (prompts.isCancel(selectedTools)) throw new UI.CancelledError()
const tools: Record<string, boolean> = {}
for (const tool of availableTools) {
if (!selectedTools.includes(tool)) {
tools[tool] = false
}
}
const frontmatter: any = {
description: generated.whenToUse,
}
if (Object.keys(tools).length > 0) {
frontmatter.tools = tools
}
const content = matter.stringify(generated.systemPrompt, frontmatter)
const filePath = path.join(
scope === "global" ? Global.Path.config : path.join(app.path.root, ".opencode"),
`agent`,
`${generated.identifier}.md`,
)
await Bun.write(filePath, content)
prompts.log.success(`Agent created: ${filePath}`)
prompts.outro("Done")
})
},
})
export const AgentCommand = cmd({
command: "agent",
describe: "manage agents",
builder: (yargs) => yargs.command(AgentCreateCommand).demandCommand(),
async handler() {},
})

View File

@@ -1,3 +1,4 @@
import { Global } from "../../../global"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
import { FileCommand } from "./file"
@@ -15,6 +16,7 @@ export const DebugCommand = cmd({
.command(FileCommand)
.command(ScrapCommand)
.command(SnapshotCommand)
.command(PathsCommand)
.command({
command: "wait",
async handler() {
@@ -26,3 +28,12 @@ export const DebugCommand = cmd({
.demandCommand(),
async handler() {},
})
const PathsCommand = cmd({
command: "paths",
handler() {
for (const [key, value] of Object.entries(Global.Path)) {
console.log(key.padEnd(10), value)
}
},
})

View File

@@ -4,49 +4,30 @@ import { cmd } from "../cmd"
export const SnapshotCommand = cmd({
command: "snapshot",
builder: (yargs) => yargs.command(CreateCommand).command(RestoreCommand).command(DiffCommand).demandCommand(),
builder: (yargs) => yargs.command(TrackCommand).command(PatchCommand).demandCommand(),
async handler() {},
})
const CreateCommand = cmd({
command: "create",
const TrackCommand = cmd({
command: "track",
async handler() {
await bootstrap({ cwd: process.cwd() }, async () => {
const result = await Snapshot.create("test")
console.log(result)
console.log(await Snapshot.track())
})
},
})
const RestoreCommand = cmd({
command: "restore <commit>",
const PatchCommand = cmd({
command: "patch <hash>",
builder: (yargs) =>
yargs.positional("commit", {
yargs.positional("hash", {
type: "string",
description: "commit",
description: "hash",
demandOption: true,
}),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
await Snapshot.restore("test", args.commit)
console.log("restored")
})
},
})
export const DiffCommand = cmd({
command: "diff <commit>",
describe: "diff",
builder: (yargs) =>
yargs.positional("commit", {
type: "string",
description: "commit",
demandOption: true,
}),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
const diff = await Snapshot.diff("test", args.commit)
console.log(diff)
console.log(await Snapshot.patch(args.hash))
})
},
})

View File

@@ -1,6 +1,5 @@
import { Server } from "../../server/server"
import fs from "fs/promises"
import path from "path"
import type { CommandModule } from "yargs"
export const GenerateCommand = {
@@ -10,6 +9,6 @@ export const GenerateCommand = {
const dir = "gen"
await fs.rmdir(dir, { recursive: true }).catch(() => {})
await fs.mkdir(dir, { recursive: true })
await Bun.write(path.join(dir, "openapi.json"), JSON.stringify(specs, null, 2))
process.stdout.write(JSON.stringify(specs, null, 2))
},
} satisfies CommandModule

View File

@@ -1,6 +1,5 @@
import { Provider } from "../../provider/provider"
import { Server } from "../../server/server"
import { Share } from "../../share/share"
import { bootstrap } from "../bootstrap"
import { cmd } from "./cmd"
@@ -32,7 +31,6 @@ export const ServeCommand = cmd({
const hostname = args.hostname
const port = args.port
await Share.init()
const server = Server.listen({
port,
hostname,

View File

@@ -12,6 +12,7 @@ import { Bus } from "../../bus"
import { Log } from "../../util/log"
import { FileWatcher } from "../../file/watch"
import { Mode } from "../../session/mode"
import { Ide } from "../../ide"
export const TuiCommand = cmd({
command: "$0 [project]",
@@ -35,6 +36,17 @@ export const TuiCommand = cmd({
.option("mode", {
type: "string",
describe: "mode to use",
})
.option("port", {
type: "number",
describe: "port to listen on",
default: 0,
})
.option("hostname", {
alias: ["h"],
type: "string",
describe: "hostname to listen on",
default: "127.0.0.1",
}),
handler: async (args) => {
while (true) {
@@ -53,8 +65,8 @@ export const TuiCommand = cmd({
}
const server = Server.listen({
port: 0,
hostname: "127.0.0.1",
port: args.port,
hostname: args.hostname,
})
let cmd = ["go", "run", "./main.go"]
@@ -116,6 +128,16 @@ export const TuiCommand = cmd({
})
.catch(() => {})
})()
;(async () => {
if (Ide.alreadyInstalled()) return
const ide = await Ide.ide()
if (ide === "unknown") return
await Ide.install(ide)
.then(() => {
Bus.publish(Ide.Event.Installed, { ide })
})
.catch(() => {})
})()
await proc.exited
server.stop()

View File

@@ -9,6 +9,7 @@ import { Global } from "../global"
import fs from "fs/promises"
import { lazy } from "../util/lazy"
import { NamedError } from "../util/error"
import matter from "gray-matter"
export namespace Config {
const log = Log.create({ service: "config" })
@@ -22,10 +23,38 @@ export namespace Config {
}
}
result.agent = result.agent || {}
const markdownAgents = [
...(await Filesystem.globUp("agent/*.md", Global.Path.config, Global.Path.config)),
...(await Filesystem.globUp(".opencode/agent/*.md", app.path.cwd, app.path.root)),
]
for (const item of markdownAgents) {
const content = await Bun.file(item).text()
const md = matter(content)
if (!md.data) continue
const config = {
name: path.basename(item, ".md"),
...md.data,
prompt: md.content.trim(),
}
const parsed = Agent.safeParse(config)
if (parsed.success) {
result.agent = mergeDeep(result.agent, {
[config.name]: parsed.data,
})
continue
}
throw new InvalidError({ path: item }, { cause: parsed.error })
}
// Handle migration from autoshare to share field
if (result.autoshare === true && !result.share) {
result.share = "auto"
}
if (result.keybinds?.messages_revert && !result.keybinds.messages_undo) {
result.keybinds.messages_undo = result.keybinds.messages_revert
}
if (!result.username) {
const os = await import("os")
@@ -72,12 +101,19 @@ export namespace Config {
model: z.string().optional(),
prompt: z.string().optional(),
tools: z.record(z.string(), z.boolean()).optional(),
disable: z.boolean().optional(),
})
.openapi({
ref: "ModeConfig",
})
export type Mode = z.infer<typeof Mode>
export const Agent = Mode.extend({
description: z.string(),
}).openapi({
ref: "AgentConfig",
})
export const Keybinds = z
.object({
leader: z.string().optional().default("ctrl+x").describe("Leader key for keybind combinations"),
@@ -89,7 +125,7 @@ export namespace Config {
session_new: z.string().optional().default("<leader>n").describe("Create a new session"),
session_list: z.string().optional().default("<leader>l").describe("List all sessions"),
session_share: z.string().optional().default("<leader>s").describe("Share current session"),
session_unshare: z.string().optional().default("<leader>u").describe("Unshare current session"),
session_unshare: z.string().optional().default("none").describe("Unshare current session"),
session_interrupt: z.string().optional().default("esc").describe("Interrupt current session"),
session_compact: z.string().optional().default("<leader>c").describe("Compact the session"),
tool_details: z.string().optional().default("<leader>d").describe("Toggle tool details"),
@@ -118,7 +154,9 @@ export namespace Config {
messages_last: z.string().optional().default("ctrl+alt+g").describe("Navigate to last message"),
messages_layout_toggle: z.string().optional().default("<leader>p").describe("Toggle layout"),
messages_copy: z.string().optional().default("<leader>y").describe("Copy message"),
messages_revert: z.string().optional().default("<leader>r").describe("Revert message"),
messages_revert: z.string().optional().default("none").describe("@deprecated use messages_undo. Revert message"),
messages_undo: z.string().optional().default("<leader>u").describe("Undo message"),
messages_redo: z.string().optional().default("<leader>r").describe("Redo message"),
app_exit: z.string().optional().default("ctrl+c,<leader>q").describe("Exit the application"),
})
.strict()
@@ -167,12 +205,27 @@ export namespace Config {
.catchall(Mode)
.optional()
.describe("Modes configuration, see https://opencode.ai/docs/modes"),
agent: z
.object({
general: Agent.optional(),
})
.catchall(Agent)
.optional()
.describe("Modes configuration, see https://opencode.ai/docs/modes"),
provider: z
.record(
ModelsDev.Provider.partial().extend({
models: z.record(ModelsDev.Model.partial()),
options: z.record(z.any()).optional(),
}),
ModelsDev.Provider.partial()
.extend({
models: z.record(ModelsDev.Model.partial()),
options: z
.object({
apiKey: z.string().optional(),
baseURL: z.string().optional(),
})
.catchall(z.any())
.optional(),
})
.strict(),
)
.optional()
.describe("Custom provider configurations and model overrides"),

View File

@@ -5,6 +5,7 @@ import { z } from "zod"
import { NamedError } from "../util/error"
import { lazy } from "../util/lazy"
import { Log } from "../util/log"
import { ZipReader, BlobReader, BlobWriter } from "@zip.js/zip.js"
export namespace Fzf {
const log = Log.create({ service: "fzf" })
@@ -45,7 +46,10 @@ export namespace Fzf {
log.info("found", { filepath })
return { filepath }
}
filepath = path.join(Global.Path.bin, "fzf" + (process.platform === "win32" ? ".exe" : ""))
filepath = path.join(
Global.Path.bin,
"fzf" + (process.platform === "win32" ? ".exe" : ""),
)
const file = Bun.file(filepath)
if (!(await file.exists())) {
@@ -53,15 +57,18 @@ export namespace Fzf {
const arch = archMap[process.arch as keyof typeof archMap] ?? "amd64"
const config = PLATFORM[process.platform as keyof typeof PLATFORM]
if (!config) throw new UnsupportedPlatformError({ platform: process.platform })
if (!config)
throw new UnsupportedPlatformError({ platform: process.platform })
const version = VERSION
const platformName = process.platform === "win32" ? "windows" : process.platform
const platformName =
process.platform === "win32" ? "windows" : process.platform
const filename = `fzf-${version}-${platformName}_${arch}.${config.extension}`
const url = `https://github.com/junegunn/fzf/releases/download/v${version}/${filename}`
const response = await fetch(url)
if (!response.ok) throw new DownloadFailedError({ url, status: response.status })
if (!response.ok)
throw new DownloadFailedError({ url, status: response.status })
const buffer = await response.arrayBuffer()
const archivePath = path.join(Global.Path.bin, filename)
@@ -80,17 +87,32 @@ export namespace Fzf {
})
}
if (config.extension === "zip") {
const proc = Bun.spawn(["unzip", "-j", archivePath, "fzf.exe", "-d", Global.Path.bin], {
cwd: Global.Path.bin,
stderr: "pipe",
stdout: "ignore",
})
await proc.exited
if (proc.exitCode !== 0)
const zipFileReader = new ZipReader(new BlobReader(new Blob([await Bun.file(archivePath).arrayBuffer()])));
const entries = await zipFileReader.getEntries();
let fzfEntry: any;
for (const entry of entries) {
if (entry.filename === "fzf.exe") {
fzfEntry = entry;
break;
}
}
if (!fzfEntry) {
throw new ExtractionFailedError({
filepath: archivePath,
stderr: await Bun.readableStreamToText(proc.stderr),
})
stderr: "fzf.exe not found in zip archive",
});
}
const fzfBlob = await fzfEntry.getData(new BlobWriter());
if (!fzfBlob) {
throw new ExtractionFailedError({
filepath: archivePath,
stderr: "Failed to extract fzf.exe from zip archive",
});
}
await Bun.write(filepath, await fzfBlob.arrayBuffer());
await zipFileReader.close();
}
await fs.unlink(archivePath)
if (process.platform !== "win32") await fs.chmod(filepath, 0o755)
@@ -105,4 +127,4 @@ export namespace Fzf {
const { filepath } = await state()
return filepath
}
}
}

View File

@@ -7,6 +7,7 @@ import { NamedError } from "../util/error"
import { lazy } from "../util/lazy"
import { $ } from "bun"
import { Fzf } from "./fzf"
import { ZipReader, BlobReader, BlobWriter } from "@zip.js/zip.js"
export namespace Ripgrep {
const Stats = z.object({
@@ -34,27 +35,25 @@ export namespace Ripgrep {
export const Match = z.object({
type: z.literal("match"),
data: z
.object({
path: z.object({
text: z.string(),
}),
lines: z.object({
text: z.string(),
}),
line_number: z.number(),
absolute_offset: z.number(),
submatches: z.array(
z.object({
match: z.object({
text: z.string(),
}),
start: z.number(),
end: z.number(),
data: z.object({
path: z.object({
text: z.string(),
}),
lines: z.object({
text: z.string(),
}),
line_number: z.number(),
absolute_offset: z.number(),
submatches: z.array(
z.object({
match: z.object({
text: z.string(),
}),
),
})
.openapi({ ref: "Match" }),
start: z.number(),
end: z.number(),
}),
),
}),
})
const End = z.object({
@@ -161,17 +160,34 @@ export namespace Ripgrep {
})
}
if (config.extension === "zip") {
const proc = Bun.spawn(["unzip", "-j", archivePath, "*/rg.exe", "-d", Global.Path.bin], {
cwd: Global.Path.bin,
stderr: "pipe",
stdout: "ignore",
})
await proc.exited
if (proc.exitCode !== 0)
throw new ExtractionFailedError({
filepath: archivePath,
stderr: await Bun.readableStreamToText(proc.stderr),
})
if (config.extension === "zip") {
const zipFileReader = new ZipReader(new BlobReader(new Blob([await Bun.file(archivePath).arrayBuffer()])))
const entries = await zipFileReader.getEntries()
let rgEntry: any
for (const entry of entries) {
if (entry.filename.endsWith("rg.exe")) {
rgEntry = entry
break
}
}
if (!rgEntry) {
throw new ExtractionFailedError({
filepath: archivePath,
stderr: "rg.exe not found in zip archive",
})
}
const rgBlob = await rgEntry.getData(new BlobWriter())
if (!rgBlob) {
throw new ExtractionFailedError({
filepath: archivePath,
stderr: "Failed to extract rg.exe from zip archive",
})
}
await Bun.write(filepath, await rgBlob.arrayBuffer())
await zipFileReader.close()
}
}
await fs.unlink(archivePath)
if (!platformKey.endsWith("-win32")) await fs.chmod(filepath, 0o755)
@@ -233,6 +249,7 @@ export namespace Ripgrep {
children: [],
}
for (const file of files) {
if (file.includes(".opencode")) continue
const parts = file.split(path.sep)
getPath(root, parts, true)
}

View File

@@ -13,7 +13,6 @@ export namespace Global {
export const Path = {
data,
bin: path.join(data, "bin"),
providers: path.join(config, "providers"),
cache,
config,
state,
@@ -23,7 +22,6 @@ export namespace Global {
await Promise.all([
fs.mkdir(Global.Path.data, { recursive: true }),
fs.mkdir(Global.Path.config, { recursive: true }),
fs.mkdir(Global.Path.providers, { recursive: true }),
fs.mkdir(Global.Path.state, { recursive: true }),
])

View File

@@ -0,0 +1,74 @@
import { $ } from "bun"
import { z } from "zod"
import { NamedError } from "../util/error"
import { Log } from "../util/log"
import { Bus } from "../bus"
const SUPPORTED_IDES = ["Windsurf", "Visual Studio Code", "Cursor", "VSCodium"] as const
export namespace Ide {
const log = Log.create({ service: "ide" })
export const Event = {
Installed: Bus.event(
"ide.installed",
z.object({
ide: z.string(),
}),
),
}
export type Ide = Awaited<ReturnType<typeof ide>>
export const AlreadyInstalledError = NamedError.create("AlreadyInstalledError", z.object({}))
export const InstallFailedError = NamedError.create(
"InstallFailedError",
z.object({
stderr: z.string(),
}),
)
export async function ide() {
if (process.env["TERM_PROGRAM"] === "vscode") {
const v = process.env["GIT_ASKPASS"]
for (const ide of SUPPORTED_IDES) {
if (v?.includes(ide)) return ide
}
}
return "unknown"
}
export function alreadyInstalled() {
return process.env["OPENCODE_CALLER"] === "vscode"
}
export async function install(ide: Ide) {
const cmd = (() => {
switch (ide) {
case "Windsurf":
return $`windsurf --install-extension sst-dev.opencode`
case "Visual Studio Code":
return $`code --install-extension sst-dev.opencode`
case "Cursor":
return $`cursor --install-extension sst-dev.opencode`
case "VSCodium":
return $`codium --install-extension sst-dev.opencode`
default:
throw new Error(`Unknown IDE: ${ide}`)
}
})()
// TODO: check OPENCODE_CALLER
const result = await cmd.quiet().throws(false)
log.info("installed", {
ide,
stdout: result.stdout.toString(),
stderr: result.stderr.toString(),
})
if (result.exitCode !== 0)
throw new InstallFailedError({
stderr: result.stderr.toString("utf8"),
})
if (result.stdout.toString().includes("already installed")) throw new AlreadyInstalledError({})
}
}

View File

@@ -5,6 +5,7 @@ import { RunCommand } from "./cli/cmd/run"
import { GenerateCommand } from "./cli/cmd/generate"
import { Log } from "./util/log"
import { AuthCommand } from "./cli/cmd/auth"
import { AgentCommand } from "./cli/cmd/agent"
import { UpgradeCommand } from "./cli/cmd/upgrade"
import { ModelsCommand } from "./cli/cmd/models"
import { UI } from "./cli/ui"
@@ -72,6 +73,7 @@ const cli = yargs(hideBin(process.argv))
.command(GenerateCommand)
.command(DebugCommand)
.command(AuthCommand)
.command(AgentCommand)
.command(UpgradeCommand)
.command(ServeCommand)
.command(ModelsCommand)

View File

@@ -13,7 +13,6 @@ import { GrepTool } from "../tool/grep"
import { ListTool } from "../tool/ls"
import { PatchTool } from "../tool/patch"
import { ReadTool } from "../tool/read"
import type { Tool } from "../tool/tool"
import { WriteTool } from "../tool/write"
import { TodoReadTool, TodoWriteTool } from "../tool/todo"
import { AuthAnthropic } from "../auth/anthropic"
@@ -487,31 +486,29 @@ export namespace Provider {
TaskTool,
]
const TOOL_MAPPING: Record<string, Tool.Info[]> = {
anthropic: TOOLS.filter((t) => t.id !== "patch"),
openai: TOOLS.map((t) => ({
...t,
parameters: optionalToNullable(t.parameters),
})),
azure: TOOLS.map((t) => ({
...t,
parameters: optionalToNullable(t.parameters),
})),
google: TOOLS.map((t) => ({
...t,
parameters: sanitizeGeminiParameters(t.parameters),
})),
}
export async function tools(providerID: string) {
/*
const cfg = await Config.get()
if (cfg.tool?.provider?.[providerID])
return cfg.tool.provider[providerID].map(
(id) => TOOLS.find((t) => t.id === id)!,
)
*/
return TOOL_MAPPING[providerID] ?? TOOLS
const result = await Promise.all(TOOLS.map((t) => t()))
switch (providerID) {
case "anthropic":
return result.filter((t) => t.id !== "patch")
case "openai":
return result.map((t) => ({
...t,
parameters: optionalToNullable(t.parameters),
}))
case "azure":
return result.map((t) => ({
...t,
parameters: optionalToNullable(t.parameters),
}))
case "google":
return result.map((t) => ({
...t,
parameters: sanitizeGeminiParameters(t.parameters),
}))
default:
return result
}
}
function sanitizeGeminiParameters(schema: z.ZodTypeAny, visited = new Set()): z.ZodTypeAny {

View File

@@ -3,7 +3,7 @@ import { unique } from "remeda"
export namespace ProviderTransform {
export function message(msgs: ModelMessage[], providerID: string, modelID: string) {
if (providerID === "anthropic" || modelID.includes("anthropic")) {
if (providerID === "anthropic" || modelID.includes("anthropic") || modelID.includes("claude")) {
const system = msgs.filter((msg) => msg.role === "system").slice(0, 2)
const final = msgs.filter((msg) => msg.role !== "system").slice(-2)

View File

@@ -17,6 +17,7 @@ import { File } from "../file"
import { LSP } from "../lsp"
import { MessageV2 } from "../session/message-v2"
import { Mode } from "../session/mode"
import { callTui, TuiRoute } from "./tui"
const ERRORS = {
400: {
@@ -57,15 +58,20 @@ export namespace Server {
})
})
.use(async (c, next) => {
log.info("request", {
method: c.req.method,
path: c.req.path,
})
const skipLogging = c.req.path === "/log"
if (!skipLogging) {
log.info("request", {
method: c.req.method,
path: c.req.path,
})
}
const start = Date.now()
await next()
log.info("response", {
duration: Date.now() - start,
})
if (!skipLogging) {
log.info("response", {
duration: Date.now() - start,
})
}
})
.get(
"/doc",
@@ -195,6 +201,7 @@ export namespace Server {
}),
async (c) => {
const sessions = await Array.fromAsync(Session.list())
sessions.sort((a, b) => b.time.updated - a.time.updated)
return c.json(sessions)
},
)
@@ -459,6 +466,62 @@ export namespace Server {
return c.json(msg)
},
)
.post(
"/session/:id/revert",
describeRoute({
description: "Revert a message",
responses: {
200: {
description: "Updated session",
content: {
"application/json": {
schema: resolver(Session.Info),
},
},
},
},
}),
zValidator(
"param",
z.object({
id: z.string(),
}),
),
zValidator("json", Session.RevertInput.omit({ sessionID: true })),
async (c) => {
const id = c.req.valid("param").id
log.info("revert", c.req.valid("json"))
const session = await Session.revert({ sessionID: id, ...c.req.valid("json") })
return c.json(session)
},
)
.post(
"/session/:id/unrevert",
describeRoute({
description: "Restore all reverted messages",
responses: {
200: {
description: "Updated session",
content: {
"application/json": {
schema: resolver(Session.Info),
},
},
},
},
}),
zValidator(
"param",
z.object({
id: z.string(),
}),
),
async (c) => {
const id = c.req.valid("param").id
const session = await Session.unrevert({ sessionID: id })
return c.json(session)
},
)
.get(
"/config/providers",
describeRoute({
@@ -703,6 +766,47 @@ export namespace Server {
return c.json(modes)
},
)
.post(
"/tui/append-prompt",
describeRoute({
description: "Append prompt to the TUI",
responses: {
200: {
description: "Prompt processed successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
zValidator(
"json",
z.object({
text: z.string(),
}),
),
async (c) => c.json(await callTui(c)),
)
.post(
"/tui/open-help",
describeRoute({
description: "Open the help dialog",
responses: {
200: {
description: "Help dialog opened successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
async (c) => c.json(await callTui(c)),
)
.route("/tui/control", TuiRoute)
return result
}

View File

@@ -0,0 +1,30 @@
import { Hono, type Context } from "hono"
import { AsyncQueue } from "../util/queue"
interface Request {
path: string
body: any
}
const request = new AsyncQueue<Request>()
const response = new AsyncQueue<any>()
export async function callTui(ctx: Context) {
const body = await ctx.req.json()
request.push({
path: ctx.req.path,
body,
})
return response.next()
}
export const TuiRoute = new Hono()
.get("/next", async (c) => {
const req = await request.next()
return c.json(req)
})
.post("/response", async (c) => {
const body = await c.req.json()
response.push(body)
return c.json(true)
})

View File

@@ -17,7 +17,6 @@ import {
import PROMPT_INITIALIZE from "../session/prompt/initialize.txt"
import PROMPT_PLAN from "../session/prompt/plan.txt"
import PROMPT_ANTHROPIC_SPOOF from "../session/prompt/anthropic_spoof.txt"
import { App } from "../app/app"
import { Bus } from "../bus"
@@ -40,6 +39,7 @@ import { MessageV2 } from "./message-v2"
import { Mode } from "./mode"
import { LSP } from "../lsp"
import { ReadTool } from "../tool/read"
import { splitWhen } from "remeda"
export namespace Session {
const log = Log.create({ service: "session" })
@@ -64,7 +64,7 @@ export namespace Session {
revert: z
.object({
messageID: z.string(),
part: z.number(),
partID: z.string().optional(),
snapshot: z.string().optional(),
})
.optional(),
@@ -118,11 +118,22 @@ export namespace Session {
const sessions = new Map<string, Info>()
const messages = new Map<string, MessageV2.Info[]>()
const pending = new Map<string, AbortController>()
const queued = new Map<
string,
{
input: ChatInput
message: MessageV2.User
parts: MessageV2.Part[]
processed: boolean
callback: (input: { info: MessageV2.Assistant; parts: MessageV2.Part[] }) => void
}[]
>()
return {
sessions,
messages,
pending,
queued,
}
},
async (state) => {
@@ -235,7 +246,7 @@ export namespace Session {
const read = await Storage.readJSON<MessageV2.Info>(p)
result.push({
info: read,
parts: await parts(sessionID, read.id),
parts: await getParts(sessionID, read.id),
})
}
result.sort((a, b) => (a.info.id > b.info.id ? 1 : -1))
@@ -246,7 +257,7 @@ export namespace Session {
return Storage.readJSON<MessageV2.Info>("session/message/" + sessionID + "/" + messageID)
}
export async function parts(sessionID: string, messageID: string) {
export async function getParts(sessionID: string, messageID: string) {
const result = [] as MessageV2.Part[]
for (const item of await Storage.list("session/part/" + sessionID + "/" + messageID)) {
const read = await Storage.readJSON<MessageV2.Part>(item)
@@ -325,6 +336,7 @@ export namespace Session {
providerID: z.string(),
modelID: z.string(),
mode: z.string().optional(),
system: z.string().optional(),
tools: z.record(z.boolean()).optional(),
parts: z.array(
z.discriminatedUnion("type", [
@@ -351,64 +363,15 @@ export namespace Session {
]),
),
})
export type ChatInput = z.infer<typeof ChatInput>
export async function chat(input: z.infer<typeof ChatInput>) {
export async function chat(
input: z.infer<typeof ChatInput>,
): Promise<{ info: MessageV2.Assistant; parts: MessageV2.Part[] }> {
const l = log.clone().tag("session", input.sessionID)
l.info("chatting")
const model = await Provider.getModel(input.providerID, input.modelID)
let msgs = await messages(input.sessionID)
const session = await get(input.sessionID)
if (session.revert) {
const trimmed = []
for (const msg of msgs) {
if (
msg.info.id > session.revert.messageID ||
(msg.info.id === session.revert.messageID && session.revert.part === 0)
) {
await Storage.remove("session/message/" + input.sessionID + "/" + msg.info.id)
await Bus.publish(MessageV2.Event.Removed, {
sessionID: input.sessionID,
messageID: msg.info.id,
})
continue
}
if (msg.info.id === session.revert.messageID) {
if (session.revert.part === 0) break
msg.parts = msg.parts.slice(0, session.revert.part)
}
trimmed.push(msg)
}
msgs = trimmed
await update(input.sessionID, (draft) => {
draft.revert = undefined
})
}
const previous = msgs.filter((x) => x.info.role === "assistant").at(-1)?.info as MessageV2.Assistant
const outputLimit = Math.min(model.info.limit.output, OUTPUT_TOKEN_MAX) || OUTPUT_TOKEN_MAX
// auto summarize if too long
if (previous && previous.tokens) {
const tokens =
previous.tokens.input + previous.tokens.cache.read + previous.tokens.cache.write + previous.tokens.output
if (model.info.limit.context && tokens > Math.max((model.info.limit.context - outputLimit) * 0.9, 0)) {
await summarize({
sessionID: input.sessionID,
providerID: input.providerID,
modelID: input.modelID,
})
return chat(input)
}
}
using abort = lock(input.sessionID)
const lastSummary = msgs.findLast((msg) => msg.info.role === "assistant" && msg.info.summary === true)
if (lastSummary) msgs = msgs.filter((msg) => msg.info.id >= lastSummary.info.id)
const inputMode = input.mode ?? "build"
const userMsg: MessageV2.Info = {
id: input.messageID ?? Identifier.ascending("message"),
role: "user",
@@ -467,12 +430,14 @@ export namespace Session {
}
}
const args = { filePath, offset, limit }
const result = await ReadTool.execute(args, {
sessionID: input.sessionID,
abort: abort.signal,
messageID: userMsg.id,
metadata: async () => {},
})
const result = await ReadTool().then((t) =>
t.execute(args, {
sessionID: input.sessionID,
abort: new AbortController().signal,
messageID: userMsg.id,
metadata: async () => {},
}),
)
return [
{
id: Identifier.ascending("part"),
@@ -533,8 +498,7 @@ export namespace Session {
]
}),
).then((x) => x.flat())
if (input.mode === "plan")
if (inputMode === "plan")
userParts.push({
id: Identifier.ascending("part"),
messageID: userMsg.id,
@@ -544,7 +508,77 @@ export namespace Session {
synthetic: true,
})
if (msgs.length === 0 && !session.parentID) {
await updateMessage(userMsg)
for (const part of userParts) {
await updatePart(part)
}
// mark session as updated since a message has been added to it
await update(input.sessionID, (_draft) => {})
if (isLocked(input.sessionID)) {
return new Promise((resolve) => {
const queue = state().queued.get(input.sessionID) ?? []
queue.push({
input: input,
message: userMsg,
parts: userParts,
processed: false,
callback: resolve,
})
state().queued.set(input.sessionID, queue)
})
}
const model = await Provider.getModel(input.providerID, input.modelID)
let msgs = await messages(input.sessionID)
const session = await get(input.sessionID)
if (session.revert) {
const messageID = session.revert.messageID
const [preserve, remove] = splitWhen(msgs, (x) => x.info.id === messageID)
msgs = preserve
for (const msg of remove) {
await Storage.remove(`session/message/${input.sessionID}/${msg.info.id}`)
await Bus.publish(MessageV2.Event.Removed, { sessionID: input.sessionID, messageID: msg.info.id })
}
const last = preserve.at(-1)
if (session.revert.partID && last) {
const partID = session.revert.partID
const [preserveParts, removeParts] = splitWhen(last.parts, (x) => x.id === partID)
last.parts = preserveParts
for (const part of removeParts) {
await Storage.remove(`session/part/${input.sessionID}/${last.info.id}/${part.id}`)
await Bus.publish(MessageV2.Event.PartRemoved, {
messageID: last.info.id,
partID: part.id,
})
}
}
}
const previous = msgs.filter((x) => x.info.role === "assistant").at(-1)?.info as MessageV2.Assistant
const outputLimit = Math.min(model.info.limit.output, OUTPUT_TOKEN_MAX) || OUTPUT_TOKEN_MAX
// auto summarize if too long
if (previous && previous.tokens) {
const tokens =
previous.tokens.input + previous.tokens.cache.read + previous.tokens.cache.write + previous.tokens.output
if (model.info.limit.context && tokens > Math.max((model.info.limit.context - outputLimit) * 0.9, 0)) {
await summarize({
sessionID: input.sessionID,
providerID: input.providerID,
modelID: input.modelID,
})
return chat(input)
}
}
using abort = lock(input.sessionID)
const lastSummary = msgs.findLast((msg) => msg.info.role === "assistant" && msg.info.summary === true)
if (lastSummary) msgs = msgs.filter((msg) => msg.info.id >= lastSummary.info.id)
if (msgs.length === 1 && !session.parentID) {
const small = (await Provider.getSmallModel(input.providerID)) ?? model
generateText({
maxOutputTokens: small.info.reasoning ? 1024 : 20,
@@ -582,14 +616,16 @@ export namespace Session {
})
.catch(() => {})
}
await updateMessage(userMsg)
for (const part of userParts) {
await updatePart(part)
}
msgs.push({ info: userMsg, parts: userParts })
const mode = await Mode.get(input.mode ?? "build")
let system = input.providerID === "anthropic" ? [PROMPT_ANTHROPIC_SPOOF.trim()] : []
const mode = await Mode.get(inputMode)
let system = SystemPrompt.header(input.providerID)
system.push(
...(() => {
if (input.system) return [input.system]
if (mode.prompt) return [mode.prompt]
return SystemPrompt.provider(input.modelID)
})(),
)
system.push(...(mode.prompt ? [mode.prompt] : SystemPrompt.provider(input.modelID)))
system.push(...(await SystemPrompt.environment()))
system.push(...(await SystemPrompt.custom()))
@@ -601,6 +637,7 @@ export namespace Session {
id: Identifier.ascending("message"),
role: "assistant",
system,
mode: inputMode,
path: {
cwd: app.path.cwd,
root: app.path.root,
@@ -633,6 +670,7 @@ export namespace Session {
description: item.description,
inputSchema: item.parameters as ZodSchema,
async execute(args, options) {
await processor.track(options.toolCallId)
const result = await item.execute(args, {
sessionID: input.sessionID,
abort: abort.signal,
@@ -671,6 +709,7 @@ export namespace Session {
const execute = item.execute
if (!execute) continue
item.execute = async (args, opts) => {
await processor.track(opts.toolCallId)
const result = await execute(args, opts)
const output = result.content
.filter((x: any) => x.type === "text")
@@ -692,6 +731,51 @@ export namespace Session {
const stream = streamText({
onError() {},
async prepareStep({ messages }) {
const queue = (state().queued.get(input.sessionID) ?? []).filter((x) => !x.processed)
if (queue.length) {
for (const item of queue) {
if (item.processed) continue
messages.push(
...MessageV2.toModelMessage([
{
info: item.message,
parts: item.parts,
},
]),
)
item.processed = true
}
assistantMsg.time.completed = Date.now()
await updateMessage(assistantMsg)
Object.assign(assistantMsg, {
id: Identifier.ascending("message"),
role: "assistant",
system,
path: {
cwd: app.path.cwd,
root: app.path.root,
},
cost: 0,
tokens: {
input: 0,
output: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
modelID: input.modelID,
providerID: input.providerID,
time: {
created: Date.now(),
},
sessionID: input.sessionID,
})
await updateMessage(assistantMsg)
}
return {
messages,
}
},
maxRetries: 10,
maxOutputTokens: outputLimit,
abortSignal: abort.signal,
@@ -726,12 +810,27 @@ export namespace Session {
}),
})
const result = await processor.process(stream)
const queued = state().queued.get(input.sessionID) ?? []
const unprocessed = queued.find((x) => !x.processed)
if (unprocessed) {
unprocessed.processed = true
return chat(unprocessed.input)
}
for (const item of queued) {
item.callback(result)
}
state().queued.delete(input.sessionID)
return result
}
function createProcessor(assistantMsg: MessageV2.Assistant, model: ModelsDev.Model) {
const toolCalls: Record<string, MessageV2.ToolPart> = {}
const snapshots: Record<string, string> = {}
return {
async track(toolCallID: string) {
const hash = await Snapshot.track()
if (hash) snapshots[toolCallID] = hash
},
partFromToolCall(toolCallID: string) {
return toolCalls[toolCallID]
},
@@ -745,15 +844,6 @@ export namespace Session {
})
switch (value.type) {
case "start":
const snapshot = await Snapshot.create(assistantMsg.sessionID)
if (snapshot)
await updatePart({
id: Identifier.ascending("part"),
messageID: assistantMsg.id,
sessionID: assistantMsg.sessionID,
type: "snapshot",
snapshot,
})
break
case "tool-input-start":
@@ -774,6 +864,9 @@ export namespace Session {
case "tool-input-delta":
break
case "tool-input-end":
break
case "tool-call": {
const match = toolCalls[value.toolCallId]
if (match) {
@@ -809,15 +902,20 @@ export namespace Session {
},
})
delete toolCalls[value.toolCallId]
const snapshot = await Snapshot.create(assistantMsg.sessionID)
if (snapshot)
await updatePart({
id: Identifier.ascending("part"),
messageID: assistantMsg.id,
sessionID: assistantMsg.sessionID,
type: "snapshot",
snapshot,
})
const snapshot = snapshots[value.toolCallId]
if (snapshot) {
const patch = await Snapshot.patch(snapshot)
if (patch.files.length) {
await updatePart({
id: Identifier.ascending("part"),
messageID: assistantMsg.id,
sessionID: assistantMsg.sessionID,
type: "patch",
hash: patch.hash,
files: patch.files,
})
}
}
}
break
}
@@ -838,15 +936,18 @@ export namespace Session {
},
})
delete toolCalls[value.toolCallId]
const snapshot = await Snapshot.create(assistantMsg.sessionID)
if (snapshot)
const snapshot = snapshots[value.toolCallId]
if (snapshot) {
const patch = await Snapshot.patch(snapshot)
await updatePart({
id: Identifier.ascending("part"),
messageID: assistantMsg.id,
sessionID: assistantMsg.sessionID,
type: "snapshot",
snapshot,
type: "patch",
hash: patch.hash,
files: patch.files,
})
}
}
break
}
@@ -957,7 +1058,7 @@ export namespace Session {
error: assistantMsg.error,
})
}
const p = await parts(assistantMsg.sessionID, assistantMsg.id)
const p = await getParts(assistantMsg.sessionID, assistantMsg.id)
for (const part of p) {
if (part.type === "tool" && part.state.status !== "completed") {
updatePart({
@@ -981,47 +1082,65 @@ export namespace Session {
}
}
export async function revert(_input: { sessionID: string; messageID: string; part: number }) {
// TODO
/*
const message = await getMessage(input.sessionID, input.messageID)
if (!message) return
const part = message.parts[input.part]
if (!part) return
export const RevertInput = z.object({
sessionID: Identifier.schema("session"),
messageID: Identifier.schema("message"),
partID: Identifier.schema("part").optional(),
})
export type RevertInput = z.infer<typeof RevertInput>
export async function revert(input: RevertInput) {
const all = await messages(input.sessionID)
let lastUser: MessageV2.User | undefined
const session = await get(input.sessionID)
const snapshot =
session.revert?.snapshot ?? (await Snapshot.create(input.sessionID))
const old = (() => {
if (message.role === "assistant") {
const lastTool = message.parts.findLast(
(part, index) =>
part.type === "tool-invocation" && index < input.part,
)
if (lastTool && lastTool.type === "tool-invocation")
return message.metadata.tool[lastTool.toolInvocation.toolCallId]
.snapshot
let revert: Info["revert"]
const patches: Snapshot.Patch[] = []
for (const msg of all) {
if (msg.info.role === "user") lastUser = msg.info
const remaining = []
for (const part of msg.parts) {
if (revert) {
if (part.type === "patch") {
patches.push(part)
}
continue
}
if (!revert) {
if ((msg.info.id === input.messageID && !input.partID) || part.id === input.partID) {
// if no useful parts left in message, same as reverting whole message
const partID = remaining.some((item) => ["text", "tool"].includes(item.type)) ? input.partID : undefined
revert = {
messageID: !partID && lastUser ? lastUser.id : msg.info.id,
partID,
}
}
remaining.push(part)
}
}
return message.metadata.snapshot
})()
if (old) await Snapshot.restore(input.sessionID, old)
await update(input.sessionID, (draft) => {
draft.revert = {
messageID: input.messageID,
part: input.part,
snapshot,
}
})
*/
}
if (revert) {
const session = await get(input.sessionID)
revert.snapshot = session.revert?.snapshot ?? (await Snapshot.track())
await Snapshot.revert(patches)
return update(input.sessionID, (draft) => {
draft.revert = revert
})
}
return session
}
export async function unrevert(sessionID: string) {
const session = await get(sessionID)
if (!session) return
if (!session.revert) return
if (session.revert.snapshot) await Snapshot.restore(sessionID, session.revert.snapshot)
update(sessionID, (draft) => {
export async function unrevert(input: { sessionID: string }) {
log.info("unreverting", input)
const session = await get(input.sessionID)
if (!session.revert) return session
if (session.revert.snapshot) await Snapshot.restore(session.revert.snapshot)
const next = await update(input.sessionID, (draft) => {
draft.revert = undefined
})
return next
}
export async function summarize(input: { sessionID: string; providerID: string; modelID: string }) {
@@ -1031,13 +1150,18 @@ export namespace Session {
const filtered = msgs.filter((msg) => !lastSummary || msg.info.id >= lastSummary.info.id)
const model = await Provider.getModel(input.providerID, input.modelID)
const app = App.info()
const system = SystemPrompt.summarize(input.providerID)
const system = [
...SystemPrompt.summarize(input.providerID),
...(await SystemPrompt.environment()),
...(await SystemPrompt.custom()),
]
const next: MessageV2.Info = {
id: Identifier.ascending("message"),
role: "assistant",
sessionID: input.sessionID,
system,
mode: "build",
path: {
cwd: app.path.cwd,
root: app.path.root,
@@ -1087,6 +1211,10 @@ export namespace Session {
return result
}
function isLocked(sessionID: string) {
return state().pending.has(sessionID)
}
function lock(sessionID: string) {
log.info("locking", { sessionID })
if (state().pending.has(sessionID)) throw new BusyError(sessionID)

View File

@@ -94,6 +94,15 @@ export namespace MessageV2 {
})
export type SnapshotPart = z.infer<typeof SnapshotPart>
export const PatchPart = PartBase.extend({
type: z.literal("patch"),
hash: z.string(),
files: z.string().array(),
}).openapi({
ref: "PatchPart",
})
export type PatchPart = z.infer<typeof PatchPart>
export const TextPart = PartBase.extend({
type: z.literal("text"),
text: z.string(),
@@ -203,7 +212,7 @@ export namespace MessageV2 {
export type User = z.infer<typeof User>
export const Part = z
.discriminatedUnion("type", [TextPart, FilePart, ToolPart, StepStartPart, StepFinishPart, SnapshotPart])
.discriminatedUnion("type", [TextPart, FilePart, ToolPart, StepStartPart, StepFinishPart, SnapshotPart, PatchPart])
.openapi({
ref: "Part",
})
@@ -226,6 +235,7 @@ export namespace MessageV2 {
system: z.string().array(),
modelID: z.string(),
providerID: z.string(),
mode: z.string(),
path: z.object({
cwd: z.string(),
root: z.string(),
@@ -271,6 +281,13 @@ export namespace MessageV2 {
part: Part,
}),
),
PartRemoved: Bus.event(
"message.part.removed",
z.object({
messageID: z.string(),
partID: z.string(),
}),
),
}
export function fromV1(v1: Message.Info) {
@@ -290,6 +307,7 @@ export namespace MessageV2 {
modelID: v1.metadata.assistant!.modelID,
providerID: v1.metadata.assistant!.providerID,
system: v1.metadata.assistant!.system,
mode: "build",
error: v1.metadata.error,
}
const parts = v1.parts.flatMap((part): Part[] => {

View File

@@ -1,7 +1,7 @@
import { mergeDeep } from "remeda"
import { App } from "../app/app"
import { Config } from "../config/config"
import z from "zod"
import { Provider } from "../provider/provider"
export namespace Mode {
export const Info = z
@@ -22,38 +22,39 @@ export namespace Mode {
export type Info = z.infer<typeof Info>
const state = App.state("mode", async () => {
const cfg = await Config.get()
const mode = mergeDeep(
{
build: {},
plan: {
tools: {
write: false,
edit: false,
patch: false,
},
const result: Record<string, Info> = {
build: {
name: "build",
tools: {},
},
plan: {
name: "plan",
tools: {
write: false,
edit: false,
patch: false,
},
},
cfg.mode ?? {},
)
const result: Record<string, Info> = {}
for (const [key, value] of Object.entries(mode)) {
}
for (const [key, value] of Object.entries(cfg.mode ?? {})) {
if (value.disable) continue
let item = result[key]
if (!item)
item = result[key] = {
name: key,
tools: {},
}
item.name = key
const model = value.model ?? cfg.model
if (model) {
const [providerID, ...rest] = model.split("/")
const modelID = rest.join("/")
item.model = {
modelID,
providerID,
}
item.model = Provider.parseModel(model)
}
if (value.prompt) item.prompt = value.prompt
if (value.tools) item.tools = value.tools
if (value.tools)
item.tools = {
...value.tools,
...item.tools,
}
}
return result

View File

@@ -14,6 +14,10 @@ import PROMPT_SUMMARIZE from "./prompt/summarize.txt"
import PROMPT_TITLE from "./prompt/title.txt"
export namespace SystemPrompt {
export function header(providerID: string) {
if (providerID.includes("anthropic")) return [PROMPT_ANTHROPIC_SPOOF.trim()]
return []
}
export function provider(modelID: string) {
if (modelID.includes("gpt-") || modelID.includes("o1") || modelID.includes("o3")) return [PROMPT_BEAST]
if (modelID.includes("gemini-")) return [PROMPT_GEMINI]

View File

@@ -2,27 +2,31 @@ import { App } from "../app/app"
import { $ } from "bun"
import path from "path"
import fs from "fs/promises"
import { Ripgrep } from "../file/ripgrep"
import { Log } from "../util/log"
import { Global } from "../global"
import { z } from "zod"
export namespace Snapshot {
const log = Log.create({ service: "snapshot" })
export async function create(sessionID: string) {
log.info("creating snapshot")
export function init() {
Array.fromAsync(
new Bun.Glob("**/snapshot").scan({
absolute: true,
onlyFiles: false,
cwd: Global.Path.data,
}),
).then((files) => {
for (const file of files) {
fs.rmdir(file, { recursive: true })
}
})
}
export async function track() {
const app = App.info()
// not a git repo, check if too big to snapshot
if (!app.git) {
const files = await Ripgrep.files({
cwd: app.path.cwd,
limit: 1000,
})
log.info("found files", { count: files.length })
if (files.length >= 1000) return
}
const git = gitdir(sessionID)
if (!app.git) return
const git = gitdir()
if (await fs.mkdir(git, { recursive: true })) {
await $`git init`
.env({
@@ -34,36 +38,64 @@ export namespace Snapshot {
.nothrow()
log.info("initialized")
}
await $`git --git-dir ${git} add .`.quiet().cwd(app.path.cwd).nothrow()
log.info("added files")
const result =
await $`git --git-dir ${git} commit -m "snapshot" --no-gpg-sign --author="opencode <mail@opencode.ai>"`
.quiet()
.cwd(app.path.cwd)
.nothrow()
const match = result.stdout.toString().match(/\[.+ ([a-f0-9]+)\]/)
if (!match) return
return match![1]
const hash = await $`git --git-dir ${git} write-tree`.quiet().cwd(app.path.cwd).text()
return hash.trim()
}
export async function restore(sessionID: string, snapshot: string) {
export const Patch = z.object({
hash: z.string(),
files: z.string().array(),
})
export type Patch = z.infer<typeof Patch>
export async function patch(hash: string): Promise<Patch> {
const app = App.info()
const git = gitdir()
await $`git --git-dir ${git} add .`.quiet().cwd(app.path.cwd).nothrow()
const files = await $`git --git-dir ${git} diff --name-only ${hash} -- .`.cwd(app.path.cwd).text()
return {
hash,
files: files
.trim()
.split("\n")
.map((x) => x.trim())
.filter(Boolean)
.map((x) => path.join(app.path.cwd, x)),
}
}
export async function restore(snapshot: string) {
log.info("restore", { commit: snapshot })
const app = App.info()
const git = gitdir(sessionID)
await $`git --git-dir=${git} checkout ${snapshot} --force`.quiet().cwd(app.path.root)
const git = gitdir()
await $`git --git-dir=${git} read-tree ${snapshot} && git --git-dir=${git} checkout-index -a -f`
.quiet()
.cwd(app.path.root)
}
export async function diff(sessionID: string, commit: string) {
const git = gitdir(sessionID)
const result = await $`git --git-dir=${git} diff -R ${commit}`.quiet().cwd(App.info().path.root)
return result.stdout.toString("utf8")
export async function revert(patches: Patch[]) {
const files = new Set<string>()
const git = gitdir()
for (const item of patches) {
for (const file of item.files) {
if (files.has(file)) continue
log.info("reverting", { file, hash: item.hash })
const result = await $`git --git-dir=${git} checkout ${item.hash} -- ${file}`
.quiet()
.cwd(App.info().path.root)
.nothrow()
if (result.exitCode !== 0) {
log.info("file not found in history, deleting", { file })
await fs.unlink(file).catch(() => {})
}
files.add(file)
}
}
}
function gitdir(sessionID: string) {
function gitdir() {
const app = App.info()
return path.join(app.path.data, "snapshot", sessionID)
return path.join(app.path.data, "snapshots")
}
}

View File

@@ -72,6 +72,22 @@ export namespace Storage {
} catch (e) {}
}
},
async (dir: string) => {
const files = new Bun.Glob("session/message/*/*.json").scanSync({
cwd: dir,
absolute: true,
})
for (const file of files) {
try {
const content = await Bun.file(file).json()
if (content.role === "assistant" && !content.mode) {
log.info("adding mode field to message", { file })
content.mode = "build"
await Bun.write(file, JSON.stringify(content, null, 2))
}
} catch (e) {}
}
},
]
const state = App.state("storage", async () => {

View File

@@ -1,7 +1,7 @@
// the approaches in this edit tool are sourced from
// https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-23-25.ts
// https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/utils/editCorrector.ts
// https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-26-25.ts
import { z } from "zod"
import * as path from "path"
import { Tool } from "./tool"
@@ -105,6 +105,31 @@ export const EditTool = Tool.define({
export type Replacer = (content: string, find: string) => Generator<string, void, unknown>
// Similarity thresholds for block anchor fallback matching
const SINGLE_CANDIDATE_SIMILARITY_THRESHOLD = 0.0
const MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD = 0.3
/**
* Levenshtein distance algorithm implementation
*/
function levenshtein(a: string, b: string): number {
// Handle empty strings
if (a === "" || b === "") {
return Math.max(a.length, b.length)
}
const matrix = Array.from({ length: a.length + 1 }, (_, i) =>
Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)),
)
for (let i = 1; i <= a.length; i++) {
for (let j = 1; j <= b.length; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1
matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost)
}
}
return matrix[a.length][b.length]
}
export const SimpleReplacer: Replacer = function* (_content, find) {
yield find
}
@@ -160,8 +185,10 @@ export const BlockAnchorReplacer: Replacer = function* (content, find) {
const firstLineSearch = searchLines[0].trim()
const lastLineSearch = searchLines[searchLines.length - 1].trim()
const searchBlockSize = searchLines.length
// Find blocks where first line matches the search first line
// Collect all candidate positions where both anchors match
const candidates: Array<{ startLine: number; endLine: number }> = []
for (let i = 0; i < originalLines.length; i++) {
if (originalLines[i].trim() !== firstLineSearch) {
continue
@@ -170,25 +197,113 @@ export const BlockAnchorReplacer: Replacer = function* (content, find) {
// Look for the matching last line after this first line
for (let j = i + 2; j < originalLines.length; j++) {
if (originalLines[j].trim() === lastLineSearch) {
// Found a potential block from i to j
let matchStartIndex = 0
for (let k = 0; k < i; k++) {
matchStartIndex += originalLines[k].length + 1
}
let matchEndIndex = matchStartIndex
for (let k = 0; k <= j - i; k++) {
matchEndIndex += originalLines[i + k].length
if (k < j - i) {
matchEndIndex += 1 // Add newline character except for the last line
}
}
yield content.substring(matchStartIndex, matchEndIndex)
candidates.push({ startLine: i, endLine: j })
break // Only match the first occurrence of the last line
}
}
}
// Return immediately if no candidates
if (candidates.length === 0) {
return
}
// Handle single candidate scenario (using relaxed threshold)
if (candidates.length === 1) {
const { startLine, endLine } = candidates[0]
const actualBlockSize = endLine - startLine + 1
let similarity = 0
let linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2) // Middle lines only
if (linesToCheck > 0) {
for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) {
const originalLine = originalLines[startLine + j].trim()
const searchLine = searchLines[j].trim()
const maxLen = Math.max(originalLine.length, searchLine.length)
if (maxLen === 0) {
continue
}
const distance = levenshtein(originalLine, searchLine)
similarity += (1 - distance / maxLen) / linesToCheck
// Exit early when threshold is reached
if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) {
break
}
}
} else {
// No middle lines to compare, just accept based on anchors
similarity = 1.0
}
if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) {
let matchStartIndex = 0
for (let k = 0; k < startLine; k++) {
matchStartIndex += originalLines[k].length + 1
}
let matchEndIndex = matchStartIndex
for (let k = startLine; k <= endLine; k++) {
matchEndIndex += originalLines[k].length
if (k < endLine) {
matchEndIndex += 1 // Add newline character except for the last line
}
}
yield content.substring(matchStartIndex, matchEndIndex)
}
return
}
// Calculate similarity for multiple candidates
let bestMatch: { startLine: number; endLine: number } | null = null
let maxSimilarity = -1
for (const candidate of candidates) {
const { startLine, endLine } = candidate
const actualBlockSize = endLine - startLine + 1
let similarity = 0
let linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2) // Middle lines only
if (linesToCheck > 0) {
for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) {
const originalLine = originalLines[startLine + j].trim()
const searchLine = searchLines[j].trim()
const maxLen = Math.max(originalLine.length, searchLine.length)
if (maxLen === 0) {
continue
}
const distance = levenshtein(originalLine, searchLine)
similarity += 1 - distance / maxLen
}
similarity /= linesToCheck // Average similarity
} else {
// No middle lines to compare, just accept based on anchors
similarity = 1.0
}
if (similarity > maxSimilarity) {
maxSimilarity = similarity
bestMatch = candidate
}
}
// Threshold judgment
if (maxSimilarity >= MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD && bestMatch) {
const { startLine, endLine } = bestMatch
let matchStartIndex = 0
for (let k = 0; k < startLine; k++) {
matchStartIndex += originalLines[k].length + 1
}
let matchEndIndex = matchStartIndex
for (let k = startLine; k <= endLine; k++) {
matchEndIndex += originalLines[k].length
if (k < endLine) {
matchEndIndex += 1
}
}
yield content.substring(matchStartIndex, matchEndIndex)
}
}
export const WhitespaceNormalizedReplacer: Replacer = function* (content, find) {
@@ -201,23 +316,23 @@ export const WhitespaceNormalizedReplacer: Replacer = function* (content, find)
const line = lines[i]
if (normalizeWhitespace(line) === normalizedFind) {
yield line
}
// Also check for substring matches within lines
const normalizedLine = normalizeWhitespace(line)
if (normalizedLine.includes(normalizedFind)) {
// Find the actual substring in the original line that matches
const words = find.trim().split(/\s+/)
if (words.length > 0) {
const pattern = words.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\s+")
try {
const regex = new RegExp(pattern)
const match = line.match(regex)
if (match) {
yield match[0]
} else {
// Only check for substring matches if the full line doesn't match
const normalizedLine = normalizeWhitespace(line)
if (normalizedLine.includes(normalizedFind)) {
// Find the actual substring in the original line that matches
const words = find.trim().split(/\s+/)
if (words.length > 0) {
const pattern = words.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\s+")
try {
const regex = new RegExp(pattern)
const match = line.match(regex)
if (match) {
yield match[0]
}
} catch (e) {
// Invalid regex pattern, skip
}
} catch (e) {
// Invalid regex pattern, skip
}
}
}
@@ -457,7 +572,7 @@ export function replace(content: string, oldString: string, newString: string, r
BlockAnchorReplacer,
WhitespaceNormalizedReplacer,
IndentationFlexibleReplacer,
// EscapeNormalizedReplacer,
EscapeNormalizedReplacer,
// TrimmedBoundaryReplacer,
// ContextAwareReplacer,
// MultiOccurrenceReplacer,

View File

@@ -10,12 +10,22 @@ export const MultiEditTool = Tool.define({
description: DESCRIPTION,
parameters: z.object({
filePath: z.string().describe("The absolute path to the file to modify"),
edits: z.array(EditTool.parameters).describe("Array of edit operations to perform sequentially on the file"),
edits: z
.array(
z.object({
filePath: z.string().describe("The absolute path to the file to modify"),
oldString: z.string().describe("The text to replace"),
newString: z.string().describe("The text to replace it with (must be different from oldString)"),
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
}),
)
.describe("Array of edit operations to perform sequentially on the file"),
}),
async execute(params, ctx) {
const tool = await EditTool()
const results = []
for (const [, edit] of params.edits.entries()) {
const result = await EditTool.execute(
const result = await tool.execute(
{
filePath: params.filePath,
oldString: edit.oldString,

View File

@@ -5,61 +5,71 @@ import { Session } from "../session"
import { Bus } from "../bus"
import { MessageV2 } from "../session/message-v2"
import { Identifier } from "../id/id"
import { Agent } from "../agent/agent"
export const TaskTool = Tool.define({
id: "task",
description: DESCRIPTION,
parameters: z.object({
description: z.string().describe("A short (3-5 words) description of the task"),
prompt: z.string().describe("The task for the agent to perform"),
}),
async execute(params, ctx) {
const session = await Session.create(ctx.sessionID)
const msg = await Session.getMessage(ctx.sessionID, ctx.messageID)
if (msg.role !== "assistant") throw new Error("Not an assistant message")
export const TaskTool = Tool.define(async () => {
const agents = await Agent.list()
const description = DESCRIPTION.replace("{agents}", agents.map((a) => `- ${a.name}: ${a.description}`).join("\n"))
return {
id: "task",
description,
parameters: z.object({
description: z.string().describe("A short (3-5 words) description of the task"),
prompt: z.string().describe("The task for the agent to perform"),
subagent_type: z.string().describe("The type of specialized agent to use for this task"),
}),
async execute(params, ctx) {
const session = await Session.create(ctx.sessionID)
const msg = await Session.getMessage(ctx.sessionID, ctx.messageID)
if (msg.role !== "assistant") throw new Error("Not an assistant message")
const agent = await Agent.get(params.subagent_type)
const messageID = Identifier.ascending("message")
const parts: Record<string, MessageV2.ToolPart> = {}
const unsub = Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => {
if (evt.properties.part.sessionID !== session.id) return
if (evt.properties.part.messageID === messageID) return
if (evt.properties.part.type !== "tool") return
parts[evt.properties.part.id] = evt.properties.part
ctx.metadata({
title: params.description,
metadata: {
summary: Object.values(parts).sort((a, b) => a.id?.localeCompare(b.id)),
},
})
})
const messageID = Identifier.ascending("message")
const parts: Record<string, MessageV2.ToolPart> = {}
const unsub = Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => {
if (evt.properties.part.sessionID !== session.id) return
if (evt.properties.part.messageID === messageID) return
if (evt.properties.part.type !== "tool") return
parts[evt.properties.part.id] = evt.properties.part
ctx.metadata({
const model = agent.model ?? {
modelID: msg.modelID,
providerID: msg.providerID,
}
ctx.abort.addEventListener("abort", () => {
Session.abort(session.id)
})
const result = await Session.chat({
messageID,
sessionID: session.id,
modelID: model.modelID,
providerID: model.providerID,
mode: msg.mode,
system: agent.prompt,
tools: agent.tools,
parts: [
{
id: Identifier.ascending("part"),
type: "text",
text: params.prompt,
},
],
})
unsub()
return {
title: params.description,
metadata: {
summary: Object.values(parts).sort((a, b) => a.id?.localeCompare(b.id)),
summary: result.parts.filter((x) => x.type === "tool"),
},
})
})
ctx.abort.addEventListener("abort", () => {
Session.abort(session.id)
})
const result = await Session.chat({
messageID,
sessionID: session.id,
modelID: msg.modelID,
providerID: msg.providerID,
tools: {
todoread: false,
todowrite: false,
},
parts: [
{
id: Identifier.ascending("part"),
type: "text",
text: params.prompt,
},
],
})
unsub()
return {
title: params.description,
metadata: {
summary: result.parts.filter((x) => x.type === "tool"),
},
output: result.parts.findLast((x) => x.type === "text")!.text,
}
},
output: result.parts.findLast((x) => x.type === "text")!.text,
}
},
}
})

View File

@@ -1,12 +1,19 @@
Launch a new agent that has access to the following tools: Bash, Glob, Grep, LS, Read, Edit, MultiEdit, Write, NotebookRead, NotebookEdit, WebFetch, TodoRead, TodoWrite, WebSearch. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries, use the Agent tool to perform the search for you.
Launch a new agent to handle complex, multi-step tasks autonomously.
Available agent types and the tools they have access to:
{agents}
When using the Task tool, you must specify a subagent_type parameter to select which agent type to use.
When to use the Agent tool:
- If you are searching for a keyword like "config" or "logger", or for questions like "which file does X?", the Agent tool is strongly recommended
- When you are instructed to execute custom slash commands. Use the Agent tool with the slash command invocation as the entire prompt. The slash command can take arguments. For example: Task(description="Check the file", prompt="/check-file path/to/file.py")
When NOT to use the Agent tool:
- If you want to read a specific file path, use the Read or Glob tool instead of the Agent tool, to find the match more quickly
- If you are searching for a specific class definition like "class Foo", use the Glob tool instead, to find the match more quickly
- If you are searching for code within a specific file or set of 2-3 files, use the Read tool instead of the Agent tool, to find the match more quickly
- Other tasks that are not related to the agent descriptions above
Usage notes:
1. Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses
@@ -14,3 +21,40 @@ Usage notes:
3. Each agent invocation is stateless. You will not be able to send additional messages to the agent, nor will the agent be able to communicate with you outside of its final report. Therefore, your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you.
4. The agent's outputs should generally be trusted
5. Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent
6. If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement.
Example usage:
<example_agent_descriptions>
"code-reviewer": use this agent after you are done writing a signficant piece of code
"greeting-responder": use this agent when to respond to user greetings with a friendly joke
</example_agent_description>
<example>
user: "Please write a function that checks if a number is prime"
assistant: Sure let me write a function that checks if a number is prime
assistant: First let me use the Write tool to write a function that checks if a number is prime
assistant: I'm going to use the Write tool to write the following code:
<code>
function isPrime(n) {
if (n <= 1) return false
for (let i = 2; i * i <= n; i++) {
if (n % i === 0) return false
}
return true
}
</code>
<commentary>
Since a signficant piece of code was written and the task was completed, now use the code-reviewer agent to review the code
</commentary>
assistant: Now let me use the code-reviewer agent to review the code
assistant: Uses the Task tool to launch the with the code-reviewer agent
</example>
<example>
user: "Hello"
<commentary>
Since the user is greeting, use the greeting-responder agent to respond with a friendly joke
</commentary>
assistant: "I'm going to use the Task tool to launch the with the greeting-responder agent"
</example>

View File

@@ -25,8 +25,8 @@ export namespace Tool {
}
export function define<Parameters extends StandardSchemaV1, Result extends Metadata>(
input: Info<Parameters, Result>,
): Info<Parameters, Result> {
return input
input: Info<Parameters, Result> | (() => Promise<Info<Parameters, Result>>),
): () => Promise<Info<Parameters, Result>> {
return input instanceof Function ? input : async () => input
}
}

View File

@@ -49,10 +49,12 @@ export namespace Filesystem {
const glob = new Bun.Glob(pattern)
for await (const match of glob.scan({
cwd: current,
absolute: true,
onlyFiles: true,
followSymlinks: true,
dot: true,
})) {
result.push(join(current, match))
result.push(match)
}
} catch {
// Skip invalid glob patterns

View File

@@ -0,0 +1,19 @@
export class AsyncQueue<T> implements AsyncIterable<T> {
private queue: T[] = []
private resolvers: ((value: T) => void)[] = []
push(item: T) {
const resolve = this.resolvers.shift()
if (resolve) resolve(item)
else this.queue.push(item)
}
async next(): Promise<T> {
if (this.queue.length > 0) return this.queue.shift()!
return new Promise((resolve) => this.resolvers.push(resolve))
}
async *[Symbol.asyncIterator]() {
while (true) yield await this.next()
}
}

View File

@@ -326,6 +326,88 @@ const testCases: TestCase[] = [
find: "const msg = `Hello\\tWorld`;",
replace: "const msg = `Hi\\tWorld`;",
},
// Test case that reproduces the greedy matching bug - now should fail due to low similarity
{
content: [
"func main() {",
" if condition {",
" doSomething()",
" }",
" processData()",
" if anotherCondition {",
" doOtherThing()",
" }",
" return mainLayout",
"}",
"",
"func helper() {",
" }",
" return mainLayout", // This should NOT be matched due to low similarity
"}",
].join("\n"),
find: [" }", " return mainLayout"].join("\n"),
replace: [" }", " // Add some code here", " return mainLayout"].join("\n"),
fail: true, // This should fail because the pattern has low similarity score
},
// Test case for the fix - more specific pattern should work
{
content: [
"function renderLayout() {",
" const header = createHeader()",
" const body = createBody()",
" return mainLayout",
"}",
].join("\n"),
find: ["function renderLayout() {", " // different content", " return mainLayout", "}"].join("\n"),
replace: [
"function renderLayout() {",
" const header = createHeader()",
" const body = createBody()",
" // Add minimap overlay",
" return mainLayout",
"}",
].join("\n"),
},
// Test that large blocks without arbitrary size limits can work
{
content: Array.from({ length: 100 }, (_, i) => `line ${i}`).join("\n"),
find: Array.from({ length: 50 }, (_, i) => `line ${i + 25}`).join("\n"),
replace: Array.from({ length: 50 }, (_, i) => `updated line ${i + 25}`).join("\n"),
},
// Test case for the fix - more specific pattern should work
{
content: [
"function renderLayout() {",
" const header = createHeader()",
" const body = createBody()",
" return mainLayout",
"}",
].join("\n"),
find: ["function renderLayout() {", " // different content", " return mainLayout", "}"].join("\n"),
replace: [
"function renderLayout() {",
" const header = createHeader()",
" const body = createBody()",
" // Add minimap overlay",
" return mainLayout",
"}",
].join("\n"),
},
// Test BlockAnchorReplacer with overly large blocks (should fail)
{
content:
Array.from({ length: 100 }, (_, i) => `line ${i}`).join("\n") +
"\nfunction test() {\n" +
Array.from({ length: 60 }, (_, i) => ` content ${i}`).join("\n") +
"\n return result\n}",
find: ["function test() {", " // different content", " return result", "}"].join("\n"),
replace: ["function test() {", " return 42", "}"].join("\n"),
},
]
describe("EditTool Replacers", () => {

View File

@@ -9,10 +9,13 @@ const ctx = {
abort: AbortSignal.any([]),
metadata: () => {},
}
const glob = await GlobTool()
const list = await ListTool()
describe("tool.glob", () => {
test("truncate", async () => {
await App.provide({ cwd: process.cwd() }, async () => {
let result = await GlobTool.execute(
let result = await glob.execute(
{
pattern: "../../node_modules/**/*",
path: undefined,
@@ -24,7 +27,7 @@ describe("tool.glob", () => {
})
test("basic", async () => {
await App.provide({ cwd: process.cwd() }, async () => {
let result = await GlobTool.execute(
let result = await glob.execute(
{
pattern: "*.json",
path: undefined,
@@ -42,7 +45,7 @@ describe("tool.glob", () => {
describe("tool.ls", () => {
test("basic", async () => {
const result = await App.provide({ cwd: process.cwd() }, async () => {
return await ListTool.execute({ path: "./example", ignore: [".git"] }, ctx)
return await list.execute({ path: "./example", ignore: [".git"] }, ctx)
})
expect(result.output).toMatchSnapshot()
})

View File

@@ -0,0 +1,15 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/debian
{
"name": "Development",
"image": "mcr.microsoft.com/devcontainers/typescript-node:latest",
"features": {
"ghcr.io/devcontainers/features/node:1": {}
},
"postCreateCommand": "yarn install",
"customizations": {
"vscode": {
"extensions": ["esbenp.prettier-vscode"]
}
}
}

88
packages/sdk/.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,88 @@
name: CI
on:
push:
branches-ignore:
- 'generated'
- 'codegen/**'
- 'integrated/**'
- 'stl-preview-head/**'
- 'stl-preview-base/**'
pull_request:
branches-ignore:
- 'stl-preview-head/**'
- 'stl-preview-base/**'
jobs:
lint:
timeout-minutes: 10
name: lint
runs-on: ${{ github.repository == 'stainless-sdks/opencode-typescript' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }}
if: github.event_name == 'push' || github.event.pull_request.head.repo.fork
steps:
- uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Bootstrap
run: ./scripts/bootstrap
- name: Check types
run: ./scripts/lint
build:
timeout-minutes: 5
name: build
runs-on: ${{ github.repository == 'stainless-sdks/opencode-typescript' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }}
if: github.event_name == 'push' || github.event.pull_request.head.repo.fork
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Bootstrap
run: ./scripts/bootstrap
- name: Check build
run: ./scripts/build
- name: Get GitHub OIDC Token
if: github.repository == 'stainless-sdks/opencode-typescript'
id: github-oidc
uses: actions/github-script@v6
with:
script: core.setOutput('github_token', await core.getIDToken());
- name: Upload tarball
if: github.repository == 'stainless-sdks/opencode-typescript'
env:
URL: https://pkg.stainless.com/s
AUTH: ${{ steps.github-oidc.outputs.github_token }}
SHA: ${{ github.sha }}
run: ./scripts/utils/upload-artifact.sh
test:
timeout-minutes: 10
name: test
runs-on: ${{ github.repository == 'stainless-sdks/opencode-typescript' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }}
if: github.event_name == 'push' || github.event.pull_request.head.repo.fork
steps:
- uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Bootstrap
run: ./scripts/bootstrap
- name: Run tests
run: ./scripts/test

View File

@@ -0,0 +1,32 @@
# This workflow is triggered when a GitHub release is created.
# It can also be run manually to re-publish to NPM in case it failed for some reason.
# You can run this workflow by navigating to https://www.github.com/sst/opencode-sdk-js/actions/workflows/publish-npm.yml
name: Publish NPM
on:
workflow_dispatch:
release:
types: [published]
jobs:
publish:
name: publish
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v3
with:
node-version: '20'
- name: Install dependencies
run: |
yarn install
- name: Publish to NPM
run: |
bash ./bin/publish-npm
env:
NPM_TOKEN: ${{ secrets.OPENCODE_NPM_TOKEN || secrets.NPM_TOKEN }}

View File

@@ -0,0 +1,21 @@
name: Release Doctor
on:
pull_request:
branches:
- main
workflow_dispatch:
jobs:
release_doctor:
name: release doctor
runs-on: ubuntu-latest
if: github.repository == 'sst/opencode-sdk-js' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'release-please') || github.head_ref == 'next')
steps:
- uses: actions/checkout@v4
- name: Check release environment
run: |
bash ./bin/check-release-environment
env:
NPM_TOKEN: ${{ secrets.OPENCODE_NPM_TOKEN || secrets.NPM_TOKEN }}

10
packages/sdk/.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
.prism.log
node_modules
yarn-error.log
codegen.log
Brewfile.lock.json
dist
dist-deno
/*.tgz
.idea/

View File

@@ -0,0 +1,7 @@
CHANGELOG.md
/ecosystem-tests/*/**
/node_modules
/deno
# don't format tsc output, will break source maps
/dist

View File

@@ -0,0 +1,7 @@
{
"arrowParens": "always",
"experimentalTernaries": true,
"printWidth": 110,
"singleQuote": true,
"trailingComma": "all"
}

View File

@@ -0,0 +1,3 @@
{
".": "0.1.0-alpha.20"
}

4
packages/sdk/.stats.yml Normal file
View File

@@ -0,0 +1,4 @@
configured_endpoints: 26
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/opencode%2Fopencode-5748199af356c3243a46a466e73b5d0bab7eaa0c56895e1d0f903d637f61d0bb.yml
openapi_spec_hash: c04f6b6be54b05d9b1283c24e870163b
config_hash: 1ae82c93499b9f0b9ba828b8919f9cb3

1
packages/sdk/Brewfile Normal file
View File

@@ -0,0 +1 @@
brew "node"

196
packages/sdk/CHANGELOG.md Normal file
View File

@@ -0,0 +1,196 @@
# Changelog
## 0.1.0-alpha.20 (2025-07-16)
Full Changelog: [v0.1.0-alpha.19...v0.1.0-alpha.20](https://github.com/sst/opencode-sdk-js/compare/v0.1.0-alpha.19...v0.1.0-alpha.20)
### Features
* **api:** api update ([d296473](https://github.com/sst/opencode-sdk-js/commit/d296473db58378932b85d1afaa60942ac5599c49))
* **api:** api update ([af2b587](https://github.com/sst/opencode-sdk-js/commit/af2b5875534a4782fac186542ecb9b04393c9b0a))
## 0.1.0-alpha.19 (2025-07-16)
Full Changelog: [v0.1.0-alpha.18...v0.1.0-alpha.19](https://github.com/sst/opencode-sdk-js/compare/v0.1.0-alpha.18...v0.1.0-alpha.19)
### Features
* **api:** api update ([2e505ef](https://github.com/sst/opencode-sdk-js/commit/2e505ef451fdcf49358189c5f76bdc42fb821352))
## 0.1.0-alpha.18 (2025-07-15)
Full Changelog: [v0.1.0-alpha.17...v0.1.0-alpha.18](https://github.com/sst/opencode-sdk-js/compare/v0.1.0-alpha.17...v0.1.0-alpha.18)
### Features
* **api:** api update ([25a23e5](https://github.com/sst/opencode-sdk-js/commit/25a23e599f1180754910961df65f0cc044aa2935))
## 0.1.0-alpha.17 (2025-07-15)
Full Changelog: [v0.1.0-alpha.16...v0.1.0-alpha.17](https://github.com/sst/opencode-sdk-js/compare/v0.1.0-alpha.16...v0.1.0-alpha.17)
### Features
* **api:** api update ([8b5d592](https://github.com/sst/opencode-sdk-js/commit/8b5d59243a0212f98269412f4483e729e2367a77))
* **api:** api update ([ebd8986](https://github.com/sst/opencode-sdk-js/commit/ebd89862c48be2742eda727c83c01430413e00c0))
## 0.1.0-alpha.16 (2025-07-15)
Full Changelog: [v0.1.0-alpha.15...v0.1.0-alpha.16](https://github.com/sst/opencode-sdk-js/compare/v0.1.0-alpha.15...v0.1.0-alpha.16)
### Features
* **api:** api update ([f26379d](https://github.com/sst/opencode-sdk-js/commit/f26379d83ae7094d6ba91c6705a97a3fbd88a55a))
### Chores
* make some internal functions async ([36b1db9](https://github.com/sst/opencode-sdk-js/commit/36b1db9ca9d47d9199e2eab5f0b454b7cd31f58f))
## 0.1.0-alpha.15 (2025-07-05)
Full Changelog: [v0.1.0-alpha.14...v0.1.0-alpha.15](https://github.com/sst/opencode-sdk-js/compare/v0.1.0-alpha.14...v0.1.0-alpha.15)
### Features
* **api:** manual updates ([f6ee467](https://github.com/sst/opencode-sdk-js/commit/f6ee46752d0c174c8b934894cf2b140864864208))
### Chores
* **internal:** codegen related update ([47a1a97](https://github.com/sst/opencode-sdk-js/commit/47a1a972e755735d6b5472c61f726ab2face9e62))
## 0.1.0-alpha.14 (2025-07-03)
Full Changelog: [v0.1.0-alpha.13...v0.1.0-alpha.14](https://github.com/sst/opencode-sdk-js/compare/v0.1.0-alpha.13...v0.1.0-alpha.14)
### Features
* **api:** api update ([a1d7cf9](https://github.com/sst/opencode-sdk-js/commit/a1d7cf948a2ff47ce4e98b4a52d0e4d213b87bf6))
### Chores
* **internal:** version bump ([f8ad145](https://github.com/sst/opencode-sdk-js/commit/f8ad145b9af0c4a465642630043e59236d5f4e8d))
## 0.1.0-alpha.13 (2025-07-03)
Full Changelog: [v0.1.0-alpha.12...v0.1.0-alpha.13](https://github.com/sst/opencode-sdk-js/compare/v0.1.0-alpha.12...v0.1.0-alpha.13)
### Bug Fixes
* avoid console usage ([f96ac97](https://github.com/sst/opencode-sdk-js/commit/f96ac97fbaf7417efda306d8727654d1a4138386))
### Chores
* add docs to RequestOptions type ([1ca6677](https://github.com/sst/opencode-sdk-js/commit/1ca667765c22b706732c61ea3d9d2823aeda0a8e))
## 0.1.0-alpha.12 (2025-07-02)
Full Changelog: [v0.1.0-alpha.11...v0.1.0-alpha.12](https://github.com/sst/opencode-sdk-js/compare/v0.1.0-alpha.11...v0.1.0-alpha.12)
### Features
* **api:** update via SDK Studio ([7739340](https://github.com/sst/opencode-sdk-js/commit/77393403648067fe937637c39e80067c347a8c5b))
## 0.1.0-alpha.11 (2025-06-30)
Full Changelog: [v0.1.0-alpha.10...v0.1.0-alpha.11](https://github.com/sst/opencode-sdk-js/compare/v0.1.0-alpha.10...v0.1.0-alpha.11)
### Features
* **api:** update via SDK Studio ([2ce98e5](https://github.com/sst/opencode-sdk-js/commit/2ce98e55bf330cca0c38f60f011ffd9063b34ea0))
## 0.1.0-alpha.10 (2025-06-30)
Full Changelog: [v0.1.0-alpha.9...v0.1.0-alpha.10](https://github.com/sst/opencode-sdk-js/compare/v0.1.0-alpha.9...v0.1.0-alpha.10)
### Features
* **api:** update via SDK Studio ([fa7c91c](https://github.com/sst/opencode-sdk-js/commit/fa7c91cc2fe52d42be7365ca2c4ce3e48c2e76ac))
### Chores
* **ci:** only run for pushes and fork pull requests ([0e850e5](https://github.com/sst/opencode-sdk-js/commit/0e850e51daac413dcf2d5e30c0ea7a1cd5346c4b))
* **client:** improve path param validation ([bc3ff0e](https://github.com/sst/opencode-sdk-js/commit/bc3ff0ee2de9af8be42deae87d12f003fb5f7aa5))
## 0.1.0-alpha.9 (2025-06-27)
Full Changelog: [v0.1.0-alpha.8...v0.1.0-alpha.9](https://github.com/sst/opencode-sdk-js/compare/v0.1.0-alpha.8...v0.1.0-alpha.9)
### Features
* **api:** update via SDK Studio ([7009d10](https://github.com/sst/opencode-sdk-js/commit/7009d10aab99be7102371cee49013ab3edae4450))
* **api:** update via SDK Studio ([e60aa00](https://github.com/sst/opencode-sdk-js/commit/e60aa0024079671e3725ee6f6bfbf8c2dad78da2))
## 0.1.0-alpha.8 (2025-06-27)
Full Changelog: [v0.1.0-alpha.7...v0.1.0-alpha.8](https://github.com/sst/opencode-sdk-js/compare/v0.1.0-alpha.7...v0.1.0-alpha.8)
### Features
* **api:** update via SDK Studio ([171e3d5](https://github.com/sst/opencode-sdk-js/commit/171e3d5f3ba69ff9ba8547dac90d85b1a0a137c1))
## 0.1.0-alpha.7 (2025-06-27)
Full Changelog: [v0.1.0-alpha.6...v0.1.0-alpha.7](https://github.com/sst/opencode-sdk-js/compare/v0.1.0-alpha.6...v0.1.0-alpha.7)
### Features
* **api:** update via SDK Studio ([14d2d04](https://github.com/sst/opencode-sdk-js/commit/14d2d04d80c1d5880940c9c70a5c1ea200df2ebc))
## 0.1.0-alpha.6 (2025-06-27)
Full Changelog: [v0.1.0-alpha.5...v0.1.0-alpha.6](https://github.com/sst/opencode-sdk-js/compare/v0.1.0-alpha.5...v0.1.0-alpha.6)
### Features
* **api:** update via SDK Studio ([45e78b2](https://github.com/sst/opencode-sdk-js/commit/45e78b2f0fca18f537de9986e358aa876fb0b686))
## 0.1.0-alpha.5 (2025-06-27)
Full Changelog: [v0.1.0-alpha.4...v0.1.0-alpha.5](https://github.com/sst/opencode-sdk-js/compare/v0.1.0-alpha.4...v0.1.0-alpha.5)
### Features
* **api:** update via SDK Studio ([10a5be9](https://github.com/sst/opencode-sdk-js/commit/10a5be9261c4ba8aeece7bb6921752f5fa6d9f28))
## 0.1.0-alpha.4 (2025-06-27)
Full Changelog: [v0.1.0-alpha.3...v0.1.0-alpha.4](https://github.com/sst/opencode-sdk-js/compare/v0.1.0-alpha.3...v0.1.0-alpha.4)
### Features
* **api:** update via SDK Studio ([20dcd17](https://github.com/sst/opencode-sdk-js/commit/20dcd171405b05801e5a56f1b40fd635259b6a94))
## 0.1.0-alpha.3 (2025-06-27)
Full Changelog: [v0.1.0-alpha.2...v0.1.0-alpha.3](https://github.com/sst/opencode-sdk-js/compare/v0.1.0-alpha.2...v0.1.0-alpha.3)
### Bug Fixes
* **ci:** release-doctor — report correct token name ([128884f](https://github.com/sst/opencode-sdk-js/commit/128884f4bc64e618177a0b090cd6d52b122a059a))
## 0.1.0-alpha.2 (2025-06-24)
Full Changelog: [v0.1.0-alpha.1...v0.1.0-alpha.2](https://github.com/sst/opencode-sdk-js/compare/v0.1.0-alpha.1...v0.1.0-alpha.2)
### Features
* **api:** update via SDK Studio ([2320f32](https://github.com/sst/opencode-sdk-js/commit/2320f32190ab58d15d00d7c3328f9fba2421536c))
## 0.1.0-alpha.1 (2025-06-24)
Full Changelog: [v0.0.1-alpha.0...v0.1.0-alpha.1](https://github.com/sst/opencode-sdk-js/compare/v0.0.1-alpha.0...v0.1.0-alpha.1)
### Features
* **api:** update via SDK Studio ([e448306](https://github.com/sst/opencode-sdk-js/commit/e4483068738cbb10233fca5a9d9d44a9c9815c8b))
* **api:** update via SDK Studio ([b222c96](https://github.com/sst/opencode-sdk-js/commit/b222c96a679a8aeecb60bcf92c247fef90c75b3d))
### Chores
* update SDK settings ([c1481ea](https://github.com/sst/opencode-sdk-js/commit/c1481ea7949c1422bedaeac278600b4ec3f58038))

View File

@@ -0,0 +1,107 @@
## Setting up the environment
This repository uses [`yarn@v1`](https://classic.yarnpkg.com/lang/en/docs/install).
Other package managers may work but are not officially supported for development.
To set up the repository, run:
```sh
$ yarn
$ yarn build
```
This will install all the required dependencies and build output files to `dist/`.
## Modifying/Adding code
Most of the SDK is generated code. Modifications to code will be persisted between generations, but may
result in merge conflicts between manual patches and changes from the generator. The generator will never
modify the contents of the `src/lib/` and `examples/` directories.
## Adding and running examples
All files in the `examples/` directory are not modified by the generator and can be freely edited or added to.
```ts
// add an example to examples/<your-example>.ts
#!/usr/bin/env -S npm run tsn -T
```
```sh
$ chmod +x examples/<your-example>.ts
# run the example against your api
$ yarn tsn -T examples/<your-example>.ts
```
## Using the repository from source
If youd like to use the repository from source, you can either install from git or link to a cloned repository:
To install via git:
```sh
$ npm install git+ssh://git@github.com:sst/opencode-sdk-js.git
```
Alternatively, to link a local copy of the repo:
```sh
# Clone
$ git clone https://www.github.com/sst/opencode-sdk-js
$ cd opencode-sdk-js
# With yarn
$ yarn link
$ cd ../my-package
$ yarn link @opencode-ai/sdk
# With pnpm
$ pnpm link --global
$ cd ../my-package
$ pnpm link -—global @opencode-ai/sdk
```
## Running tests
Most tests require you to [set up a mock server](https://github.com/stoplightio/prism) against the OpenAPI spec to run the tests.
```sh
$ npx prism mock path/to/your/openapi.yml
```
```sh
$ yarn run test
```
## Linting and formatting
This repository uses [prettier](https://www.npmjs.com/package/prettier) and
[eslint](https://www.npmjs.com/package/eslint) to format the code in the repository.
To lint:
```sh
$ yarn lint
```
To format and fix all lint issues automatically:
```sh
$ yarn fix
```
## Publishing and releases
Changes made to this repository via the automated release PR pipeline should publish to npm automatically. If
the changes aren't made through the automated pipeline, you may want to make releases manually.
### Publish with a GitHub workflow
You can release to package managers by using [the `Publish NPM` GitHub action](https://www.github.com/sst/opencode-sdk-js/actions/workflows/publish-npm.yml). This requires a setup organization or repository secret to be set up.
### Publish manually
If you need to manually release a package, you can run the `bin/publish-npm` script with an `NPM_TOKEN` set on
the environment.

7
packages/sdk/LICENSE Normal file
View File

@@ -0,0 +1,7 @@
Copyright 2025 opencode
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

370
packages/sdk/README.md Normal file
View File

@@ -0,0 +1,370 @@
# Opencode TypeScript API Library
[![NPM version](<https://img.shields.io/npm/v/@opencode-ai/sdk.svg?label=npm%20(stable)>)](https://npmjs.org/package/@opencode-ai/sdk) ![npm bundle size](https://img.shields.io/bundlephobia/minzip/@opencode-ai/sdk)
This library provides convenient access to the Opencode REST API from server-side TypeScript or JavaScript.
The REST API documentation can be found on [opencode.ai](https://opencode.ai/docs). The full API of this library can be found in [api.md](api.md).
It is generated with [Stainless](https://www.stainless.com/).
## Installation
```sh
npm install @opencode-ai/sdk
```
## Usage
The full API of this library can be found in [api.md](api.md).
<!-- prettier-ignore -->
```js
import Opencode from '@opencode-ai/sdk';
const client = new Opencode();
const sessions = await client.session.list();
```
## Streaming responses
We provide support for streaming responses using Server Sent Events (SSE).
```ts
import Opencode from '@opencode-ai/sdk';
const client = new Opencode();
const stream = await client.event.list();
for await (const eventListResponse of stream) {
console.log(eventListResponse);
}
```
If you need to cancel a stream, you can `break` from the loop
or call `stream.controller.abort()`.
### Request & Response types
This library includes TypeScript definitions for all request params and response fields. You may import and use them like so:
<!-- prettier-ignore -->
```ts
import Opencode from '@opencode-ai/sdk';
const client = new Opencode();
const sessions: Opencode.SessionListResponse = await client.session.list();
```
Documentation for each method, request param, and response field are available in docstrings and will appear on hover in most modern editors.
## Handling errors
When the library is unable to connect to the API,
or if the API returns a non-success status code (i.e., 4xx or 5xx response),
a subclass of `APIError` will be thrown:
<!-- prettier-ignore -->
```ts
const sessions = await client.session.list().catch(async (err) => {
if (err instanceof Opencode.APIError) {
console.log(err.status); // 400
console.log(err.name); // BadRequestError
console.log(err.headers); // {server: 'nginx', ...}
} else {
throw err;
}
});
```
Error codes are as follows:
| Status Code | Error Type |
| ----------- | -------------------------- |
| 400 | `BadRequestError` |
| 401 | `AuthenticationError` |
| 403 | `PermissionDeniedError` |
| 404 | `NotFoundError` |
| 422 | `UnprocessableEntityError` |
| 429 | `RateLimitError` |
| >=500 | `InternalServerError` |
| N/A | `APIConnectionError` |
### Retries
Certain errors will be automatically retried 2 times by default, with a short exponential backoff.
Connection errors (for example, due to a network connectivity problem), 408 Request Timeout, 409 Conflict,
429 Rate Limit, and >=500 Internal errors will all be retried by default.
You can use the `maxRetries` option to configure or disable this:
<!-- prettier-ignore -->
```js
// Configure the default for all requests:
const client = new Opencode({
maxRetries: 0, // default is 2
});
// Or, configure per-request:
await client.session.list({
maxRetries: 5,
});
```
### Timeouts
Requests time out after 1 minute by default. You can configure this with a `timeout` option:
<!-- prettier-ignore -->
```ts
// Configure the default for all requests:
const client = new Opencode({
timeout: 20 * 1000, // 20 seconds (default is 1 minute)
});
// Override per-request:
await client.session.list({
timeout: 5 * 1000,
});
```
On timeout, an `APIConnectionTimeoutError` is thrown.
Note that requests which time out will be [retried twice by default](#retries).
## Advanced Usage
### Accessing raw Response data (e.g., headers)
The "raw" `Response` returned by `fetch()` can be accessed through the `.asResponse()` method on the `APIPromise` type that all methods return.
This method returns as soon as the headers for a successful response are received and does not consume the response body, so you are free to write custom parsing or streaming logic.
You can also use the `.withResponse()` method to get the raw `Response` along with the parsed data.
Unlike `.asResponse()` this method consumes the body, returning once it is parsed.
<!-- prettier-ignore -->
```ts
const client = new Opencode();
const response = await client.session.list().asResponse();
console.log(response.headers.get('X-My-Header'));
console.log(response.statusText); // access the underlying Response object
const { data: sessions, response: raw } = await client.session.list().withResponse();
console.log(raw.headers.get('X-My-Header'));
console.log(sessions);
```
### Logging
> [!IMPORTANT]
> All log messages are intended for debugging only. The format and content of log messages
> may change between releases.
#### Log levels
The log level can be configured in two ways:
1. Via the `OPENCODE_LOG` environment variable
2. Using the `logLevel` client option (overrides the environment variable if set)
```ts
import Opencode from '@opencode-ai/sdk';
const client = new Opencode({
logLevel: 'debug', // Show all log messages
});
```
Available log levels, from most to least verbose:
- `'debug'` - Show debug messages, info, warnings, and errors
- `'info'` - Show info messages, warnings, and errors
- `'warn'` - Show warnings and errors (default)
- `'error'` - Show only errors
- `'off'` - Disable all logging
At the `'debug'` level, all HTTP requests and responses are logged, including headers and bodies.
Some authentication-related headers are redacted, but sensitive data in request and response bodies
may still be visible.
#### Custom logger
By default, this library logs to `globalThis.console`. You can also provide a custom logger.
Most logging libraries are supported, including [pino](https://www.npmjs.com/package/pino), [winston](https://www.npmjs.com/package/winston), [bunyan](https://www.npmjs.com/package/bunyan), [consola](https://www.npmjs.com/package/consola), [signale](https://www.npmjs.com/package/signale), and [@std/log](https://jsr.io/@std/log). If your logger doesn't work, please open an issue.
When providing a custom logger, the `logLevel` option still controls which messages are emitted, messages
below the configured level will not be sent to your logger.
```ts
import Opencode from '@opencode-ai/sdk';
import pino from 'pino';
const logger = pino();
const client = new Opencode({
logger: logger.child({ name: 'Opencode' }),
logLevel: 'debug', // Send all messages to pino, allowing it to filter
});
```
### Making custom/undocumented requests
This library is typed for convenient access to the documented API. If you need to access undocumented
endpoints, params, or response properties, the library can still be used.
#### Undocumented endpoints
To make requests to undocumented endpoints, you can use `client.get`, `client.post`, and other HTTP verbs.
Options on the client, such as retries, will be respected when making these requests.
```ts
await client.post('/some/path', {
body: { some_prop: 'foo' },
query: { some_query_arg: 'bar' },
});
```
#### Undocumented request params
To make requests using undocumented parameters, you may use `// @ts-expect-error` on the undocumented
parameter. This library doesn't validate at runtime that the request matches the type, so any extra values you
send will be sent as-is.
```ts
client.session.list({
// ...
// @ts-expect-error baz is not yet public
baz: 'undocumented option',
});
```
For requests with the `GET` verb, any extra params will be in the query, all other requests will send the
extra param in the body.
If you want to explicitly send an extra argument, you can do so with the `query`, `body`, and `headers` request
options.
#### Undocumented response properties
To access undocumented response properties, you may access the response object with `// @ts-expect-error` on
the response object, or cast the response object to the requisite type. Like the request params, we do not
validate or strip extra properties from the response from the API.
### Customizing the fetch client
By default, this library expects a global `fetch` function is defined.
If you want to use a different `fetch` function, you can either polyfill the global:
```ts
import fetch from 'my-fetch';
globalThis.fetch = fetch;
```
Or pass it to the client:
```ts
import Opencode from '@opencode-ai/sdk';
import fetch from 'my-fetch';
const client = new Opencode({ fetch });
```
### Fetch options
If you want to set custom `fetch` options without overriding the `fetch` function, you can provide a `fetchOptions` object when instantiating the client or making a request. (Request-specific options override client options.)
```ts
import Opencode from '@opencode-ai/sdk';
const client = new Opencode({
fetchOptions: {
// `RequestInit` options
},
});
```
#### Configuring proxies
To modify proxy behavior, you can provide custom `fetchOptions` that add runtime-specific proxy
options to requests:
<img src="https://raw.githubusercontent.com/stainless-api/sdk-assets/refs/heads/main/node.svg" align="top" width="18" height="21"> **Node** <sup>[[docs](https://github.com/nodejs/undici/blob/main/docs/docs/api/ProxyAgent.md#example---proxyagent-with-fetch)]</sup>
```ts
import Opencode from '@opencode-ai/sdk';
import * as undici from 'undici';
const proxyAgent = new undici.ProxyAgent('http://localhost:8888');
const client = new Opencode({
fetchOptions: {
dispatcher: proxyAgent,
},
});
```
<img src="https://raw.githubusercontent.com/stainless-api/sdk-assets/refs/heads/main/bun.svg" align="top" width="18" height="21"> **Bun** <sup>[[docs](https://bun.sh/guides/http/proxy)]</sup>
```ts
import Opencode from '@opencode-ai/sdk';
const client = new Opencode({
fetchOptions: {
proxy: 'http://localhost:8888',
},
});
```
<img src="https://raw.githubusercontent.com/stainless-api/sdk-assets/refs/heads/main/deno.svg" align="top" width="18" height="21"> **Deno** <sup>[[docs](https://docs.deno.com/api/deno/~/Deno.createHttpClient)]</sup>
```ts
import Opencode from 'npm:@opencode-ai/sdk';
const httpClient = Deno.createHttpClient({ proxy: { url: 'http://localhost:8888' } });
const client = new Opencode({
fetchOptions: {
client: httpClient,
},
});
```
## Frequently Asked Questions
## Semantic versioning
This package generally follows [SemVer](https://semver.org/spec/v2.0.0.html) conventions, though certain backwards-incompatible changes may be released as minor versions:
1. Changes that only affect static types, without breaking runtime behavior.
2. Changes to library internals which are technically public but not intended or documented for external use. _(Please open a GitHub issue to let us know if you are relying on such internals.)_
3. Changes that we do not expect to impact the vast majority of users in practice.
We take backwards-compatibility seriously and work hard to ensure you can rely on a smooth upgrade experience.
We are keen for your feedback; please open an [issue](https://www.github.com/sst/opencode-sdk-js/issues) with questions, bugs, or suggestions.
## Requirements
TypeScript >= 4.9 is supported.
The following runtimes are supported:
- Web browsers (Up-to-date Chrome, Firefox, Safari, Edge, and more)
- Node.js 20 LTS or later ([non-EOL](https://endoflife.date/nodejs)) versions.
- Deno v1.28.0 or higher.
- Bun 1.0 or later.
- Cloudflare Workers.
- Vercel Edge Runtime.
- Jest 28 or greater with the `"node"` environment (`"jsdom"` is not supported at this time).
- Nitro v2.6 or greater.
Note that React Native is not supported at this time.
If you are interested in other runtime environments, please open or upvote an issue on GitHub.
## Contributing
See [the contributing documentation](./CONTRIBUTING.md).

27
packages/sdk/SECURITY.md Normal file
View File

@@ -0,0 +1,27 @@
# Security Policy
## Reporting Security Issues
This SDK is generated by [Stainless Software Inc](http://stainless.com). Stainless takes security seriously, and encourages you to report any security vulnerability promptly so that appropriate action can be taken.
To report a security issue, please contact the Stainless team at security@stainless.com.
## Responsible Disclosure
We appreciate the efforts of security researchers and individuals who help us maintain the security of
SDKs we generate. If you believe you have found a security vulnerability, please adhere to responsible
disclosure practices by allowing us a reasonable amount of time to investigate and address the issue
before making any information public.
## Reporting Non-SDK Related Security Issues
If you encounter security issues that are not directly related to SDKs but pertain to the services
or products provided by Opencode, please follow the respective company's security reporting guidelines.
### Opencode Terms and Policies
Please contact support@sst.dev for any questions or concerns regarding the security of our services.
---
Thank you for helping us keep the SDKs and systems they interact with secure.

140
packages/sdk/api.md Normal file
View File

@@ -0,0 +1,140 @@
# Shared
Types:
- <code><a href="./src/resources/shared.ts">MessageAbortedError</a></code>
- <code><a href="./src/resources/shared.ts">ProviderAuthError</a></code>
- <code><a href="./src/resources/shared.ts">UnknownError</a></code>
# Event
Types:
- <code><a href="./src/resources/event.ts">EventListResponse</a></code>
Methods:
- <code title="get /event">client.event.<a href="./src/resources/event.ts">list</a>() -> EventListResponse</code>
# App
Types:
- <code><a href="./src/resources/app.ts">App</a></code>
- <code><a href="./src/resources/app.ts">Mode</a></code>
- <code><a href="./src/resources/app.ts">Model</a></code>
- <code><a href="./src/resources/app.ts">Provider</a></code>
- <code><a href="./src/resources/app.ts">AppInitResponse</a></code>
- <code><a href="./src/resources/app.ts">AppLogResponse</a></code>
- <code><a href="./src/resources/app.ts">AppModesResponse</a></code>
- <code><a href="./src/resources/app.ts">AppProvidersResponse</a></code>
Methods:
- <code title="get /app">client.app.<a href="./src/resources/app.ts">get</a>() -> App</code>
- <code title="post /app/init">client.app.<a href="./src/resources/app.ts">init</a>() -> AppInitResponse</code>
- <code title="post /log">client.app.<a href="./src/resources/app.ts">log</a>({ ...params }) -> AppLogResponse</code>
- <code title="get /mode">client.app.<a href="./src/resources/app.ts">modes</a>() -> AppModesResponse</code>
- <code title="get /config/providers">client.app.<a href="./src/resources/app.ts">providers</a>() -> AppProvidersResponse</code>
# Find
Types:
- <code><a href="./src/resources/find.ts">Match</a></code>
- <code><a href="./src/resources/find.ts">Symbol</a></code>
- <code><a href="./src/resources/find.ts">FindFilesResponse</a></code>
- <code><a href="./src/resources/find.ts">FindSymbolsResponse</a></code>
- <code><a href="./src/resources/find.ts">FindTextResponse</a></code>
Methods:
- <code title="get /find/file">client.find.<a href="./src/resources/find.ts">files</a>({ ...params }) -> FindFilesResponse</code>
- <code title="get /find/symbol">client.find.<a href="./src/resources/find.ts">symbols</a>({ ...params }) -> FindSymbolsResponse</code>
- <code title="get /find">client.find.<a href="./src/resources/find.ts">text</a>({ ...params }) -> FindTextResponse</code>
# File
Types:
- <code><a href="./src/resources/file.ts">File</a></code>
- <code><a href="./src/resources/file.ts">FileReadResponse</a></code>
- <code><a href="./src/resources/file.ts">FileStatusResponse</a></code>
Methods:
- <code title="get /file">client.file.<a href="./src/resources/file.ts">read</a>({ ...params }) -> FileReadResponse</code>
- <code title="get /file/status">client.file.<a href="./src/resources/file.ts">status</a>() -> FileStatusResponse</code>
# Config
Types:
- <code><a href="./src/resources/config.ts">Config</a></code>
- <code><a href="./src/resources/config.ts">KeybindsConfig</a></code>
- <code><a href="./src/resources/config.ts">McpLocalConfig</a></code>
- <code><a href="./src/resources/config.ts">McpRemoteConfig</a></code>
- <code><a href="./src/resources/config.ts">ModeConfig</a></code>
Methods:
- <code title="get /config">client.config.<a href="./src/resources/config.ts">get</a>() -> Config</code>
# Session
Types:
- <code><a href="./src/resources/session.ts">AssistantMessage</a></code>
- <code><a href="./src/resources/session.ts">FilePart</a></code>
- <code><a href="./src/resources/session.ts">FilePartInput</a></code>
- <code><a href="./src/resources/session.ts">FilePartSource</a></code>
- <code><a href="./src/resources/session.ts">FilePartSourceText</a></code>
- <code><a href="./src/resources/session.ts">FileSource</a></code>
- <code><a href="./src/resources/session.ts">Message</a></code>
- <code><a href="./src/resources/session.ts">Part</a></code>
- <code><a href="./src/resources/session.ts">Session</a></code>
- <code><a href="./src/resources/session.ts">SnapshotPart</a></code>
- <code><a href="./src/resources/session.ts">StepFinishPart</a></code>
- <code><a href="./src/resources/session.ts">StepStartPart</a></code>
- <code><a href="./src/resources/session.ts">SymbolSource</a></code>
- <code><a href="./src/resources/session.ts">TextPart</a></code>
- <code><a href="./src/resources/session.ts">TextPartInput</a></code>
- <code><a href="./src/resources/session.ts">ToolPart</a></code>
- <code><a href="./src/resources/session.ts">ToolStateCompleted</a></code>
- <code><a href="./src/resources/session.ts">ToolStateError</a></code>
- <code><a href="./src/resources/session.ts">ToolStatePending</a></code>
- <code><a href="./src/resources/session.ts">ToolStateRunning</a></code>
- <code><a href="./src/resources/session.ts">UserMessage</a></code>
- <code><a href="./src/resources/session.ts">SessionListResponse</a></code>
- <code><a href="./src/resources/session.ts">SessionDeleteResponse</a></code>
- <code><a href="./src/resources/session.ts">SessionAbortResponse</a></code>
- <code><a href="./src/resources/session.ts">SessionInitResponse</a></code>
- <code><a href="./src/resources/session.ts">SessionMessagesResponse</a></code>
- <code><a href="./src/resources/session.ts">SessionSummarizeResponse</a></code>
Methods:
- <code title="post /session">client.session.<a href="./src/resources/session.ts">create</a>() -> Session</code>
- <code title="get /session">client.session.<a href="./src/resources/session.ts">list</a>() -> SessionListResponse</code>
- <code title="delete /session/{id}">client.session.<a href="./src/resources/session.ts">delete</a>(id) -> SessionDeleteResponse</code>
- <code title="post /session/{id}/abort">client.session.<a href="./src/resources/session.ts">abort</a>(id) -> SessionAbortResponse</code>
- <code title="post /session/{id}/message">client.session.<a href="./src/resources/session.ts">chat</a>(id, { ...params }) -> AssistantMessage</code>
- <code title="post /session/{id}/init">client.session.<a href="./src/resources/session.ts">init</a>(id, { ...params }) -> SessionInitResponse</code>
- <code title="get /session/{id}/message">client.session.<a href="./src/resources/session.ts">messages</a>(id) -> SessionMessagesResponse</code>
- <code title="post /session/{id}/revert">client.session.<a href="./src/resources/session.ts">revert</a>(id, { ...params }) -> Session</code>
- <code title="post /session/{id}/share">client.session.<a href="./src/resources/session.ts">share</a>(id) -> Session</code>
- <code title="post /session/{id}/summarize">client.session.<a href="./src/resources/session.ts">summarize</a>(id, { ...params }) -> SessionSummarizeResponse</code>
- <code title="post /session/{id}/unrevert">client.session.<a href="./src/resources/session.ts">unrevert</a>(id) -> Session</code>
- <code title="delete /session/{id}/share">client.session.<a href="./src/resources/session.ts">unshare</a>(id) -> Session</code>
# Tui
Types:
- <code><a href="./src/resources/tui.ts">TuiAppendPromptResponse</a></code>
- <code><a href="./src/resources/tui.ts">TuiOpenHelpResponse</a></code>
Methods:
- <code title="post /tui/append-prompt">client.tui.<a href="./src/resources/tui.ts">appendPrompt</a>({ ...params }) -> TuiAppendPromptResponse</code>
- <code title="post /tui/open-help">client.tui.<a href="./src/resources/tui.ts">openHelp</a>() -> TuiOpenHelpResponse</code>

View File

@@ -0,0 +1,22 @@
#!/usr/bin/env bash
errors=()
if [ -z "${NPM_TOKEN}" ]; then
errors+=("The NPM_TOKEN secret has not been set. Please set it in either this repository's secrets or your organization secrets")
fi
lenErrors=${#errors[@]}
if [[ lenErrors -gt 0 ]]; then
echo -e "Found the following errors in the release environment:\n"
for error in "${errors[@]}"; do
echo -e "- $error\n"
done
exit 1
fi
echo "The environment is ready to push releases!"

View File

@@ -0,0 +1,61 @@
#!/usr/bin/env bash
set -eux
npm config set '//registry.npmjs.org/:_authToken' "$NPM_TOKEN"
yarn build
cd dist
# Get package name and version from package.json
PACKAGE_NAME="$(jq -r -e '.name' ./package.json)"
VERSION="$(jq -r -e '.version' ./package.json)"
# Get latest version from npm
#
# If the package doesn't exist, npm will return:
# {
# "error": {
# "code": "E404",
# "summary": "Unpublished on 2025-06-05T09:54:53.528Z",
# "detail": "'the_package' is not in this registry..."
# }
# }
NPM_INFO="$(npm view "$PACKAGE_NAME" version --json 2>/dev/null || true)"
# Check if we got an E404 error
if echo "$NPM_INFO" | jq -e '.error.code == "E404"' > /dev/null 2>&1; then
# Package doesn't exist yet, no last version
LAST_VERSION=""
elif echo "$NPM_INFO" | jq -e '.error' > /dev/null 2>&1; then
# Report other errors
echo "ERROR: npm returned unexpected data:"
echo "$NPM_INFO"
exit 1
else
# Success - get the version
LAST_VERSION=$(echo "$NPM_INFO" | jq -r '.') # strip quotes
fi
# Check if current version is pre-release (e.g. alpha / beta / rc)
CURRENT_IS_PRERELEASE=false
if [[ "$VERSION" =~ -([a-zA-Z]+) ]]; then
CURRENT_IS_PRERELEASE=true
CURRENT_TAG="${BASH_REMATCH[1]}"
fi
# Check if last version is a stable release
LAST_IS_STABLE_RELEASE=true
if [[ -z "$LAST_VERSION" || "$LAST_VERSION" =~ -([a-zA-Z]+) ]]; then
LAST_IS_STABLE_RELEASE=false
fi
# Use a corresponding alpha/beta tag if there already is a stable release and we're publishing a prerelease.
if $CURRENT_IS_PRERELEASE && $LAST_IS_STABLE_RELEASE; then
TAG="$CURRENT_TAG"
else
TAG="latest"
fi
# Publish with the appropriate tag
yarn publish --access public --tag "$TAG"

View File

@@ -0,0 +1,42 @@
// @ts-check
import tseslint from 'typescript-eslint';
import unusedImports from 'eslint-plugin-unused-imports';
import prettier from 'eslint-plugin-prettier';
export default tseslint.config(
{
languageOptions: {
parser: tseslint.parser,
parserOptions: { sourceType: 'module' },
},
files: ['**/*.ts', '**/*.mts', '**/*.cts', '**/*.js', '**/*.mjs', '**/*.cjs'],
ignores: ['dist/'],
plugins: {
'@typescript-eslint': tseslint.plugin,
'unused-imports': unusedImports,
prettier,
},
rules: {
'no-unused-vars': 'off',
'prettier/prettier': 'error',
'unused-imports/no-unused-imports': 'error',
'no-restricted-imports': [
'error',
{
patterns: [
{
regex: '^@opencode-ai/sdk(/.*)?',
message: 'Use a relative import, not a package import.',
},
],
},
],
},
},
{
files: ['tests/**', 'examples/**'],
rules: {
'no-restricted-imports': 'off',
},
},
);

View File

@@ -0,0 +1,4 @@
File generated from our OpenAPI spec by Stainless.
This directory can be used to store example files demonstrating usage of this SDK.
It is ignored by Stainless code generation and its content (other than this keep file) won't be touched.

View File

@@ -0,0 +1,23 @@
import type { JestConfigWithTsJest } from 'ts-jest';
const config: JestConfigWithTsJest = {
preset: 'ts-jest/presets/default-esm',
testEnvironment: 'node',
transform: {
'^.+\\.(t|j)sx?$': ['@swc/jest', { sourceMaps: 'inline' }],
},
moduleNameMapper: {
'^@opencode-ai/sdk$': '<rootDir>/src/index.ts',
'^@opencode-ai/sdk/(.*)$': '<rootDir>/src/$1',
},
modulePathIgnorePatterns: [
'<rootDir>/ecosystem-tests/',
'<rootDir>/dist/',
'<rootDir>/deno/',
'<rootDir>/deno_tests/',
'<rootDir>/packages/',
],
testPathIgnorePatterns: ['scripts'],
};
export default config;

69
packages/sdk/package.json Normal file
View File

@@ -0,0 +1,69 @@
{
"name": "@opencode-ai/sdk",
"version": "0.1.0-alpha.20",
"description": "The official TypeScript library for the Opencode API",
"author": "Opencode <support@sst.dev>",
"types": "dist/index.d.ts",
"main": "dist/index.js",
"type": "commonjs",
"repository": "github:sst/opencode-sdk-js",
"license": "MIT",
"packageManager": "yarn@1.22.22",
"files": [
"**/*"
],
"private": false,
"scripts": {
"test": "./scripts/test",
"build": "./scripts/build",
"prepublishOnly": "echo 'to publish, run yarn build && (cd dist; yarn publish)' && exit 1",
"format": "./scripts/format",
"prepare": "if ./scripts/utils/check-is-in-git-install.sh; then ./scripts/build && ./scripts/utils/git-swap.sh; fi",
"tsn": "ts-node -r tsconfig-paths/register",
"lint": "./scripts/lint",
"fix": "./scripts/format"
},
"dependencies": {},
"devDependencies": {
"@arethetypeswrong/cli": "^0.17.0",
"@swc/core": "^1.3.102",
"@swc/jest": "^0.2.29",
"@types/jest": "^29.4.0",
"@types/node": "^20.17.6",
"@typescript-eslint/eslint-plugin": "8.31.1",
"@typescript-eslint/parser": "8.31.1",
"eslint": "^9.20.1",
"eslint-plugin-prettier": "^5.4.1",
"eslint-plugin-unused-imports": "^4.1.4",
"iconv-lite": "^0.6.3",
"jest": "^29.4.0",
"prettier": "^3.0.0",
"publint": "^0.2.12",
"ts-jest": "^29.1.0",
"ts-node": "^10.5.0",
"tsc-multi": "https://github.com/stainless-api/tsc-multi/releases/download/v1.1.8/tsc-multi.tgz",
"tsconfig-paths": "^4.0.0",
"typescript": "5.8.3",
"typescript-eslint": "8.31.1"
},
"imports": {
"@opencode-ai/sdk": ".",
"@opencode-ai/sdk/*": "./src/*"
},
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.js"
},
"./*.mjs": {
"default": "./dist/*.mjs"
},
"./*.js": {
"default": "./dist/*.js"
},
"./*": {
"import": "./dist/*.mjs",
"require": "./dist/*.js"
}
}
}

View File

@@ -0,0 +1,64 @@
{
"packages": {
".": {}
},
"$schema": "https://raw.githubusercontent.com/stainless-api/release-please/main/schemas/config.json",
"include-v-in-tag": true,
"include-component-in-tag": false,
"versioning": "prerelease",
"prerelease": true,
"bump-minor-pre-major": true,
"bump-patch-for-minor-pre-major": false,
"pull-request-header": "Automated Release PR",
"pull-request-title-pattern": "release: ${version}",
"changelog-sections": [
{
"type": "feat",
"section": "Features"
},
{
"type": "fix",
"section": "Bug Fixes"
},
{
"type": "perf",
"section": "Performance Improvements"
},
{
"type": "revert",
"section": "Reverts"
},
{
"type": "chore",
"section": "Chores"
},
{
"type": "docs",
"section": "Documentation"
},
{
"type": "style",
"section": "Styles"
},
{
"type": "refactor",
"section": "Refactors"
},
{
"type": "test",
"section": "Tests",
"hidden": true
},
{
"type": "build",
"section": "Build System"
},
{
"type": "ci",
"section": "Continuous Integration",
"hidden": true
}
],
"release-type": "node",
"extra-files": ["src/version.ts", "README.md"]
}

18
packages/sdk/scripts/bootstrap Executable file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -e
cd "$(dirname "$0")/.."
if [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ] && [ "$SKIP_BREW" != "1" ]; then
brew bundle check >/dev/null 2>&1 || {
echo "==> Installing Homebrew dependencies…"
brew bundle
}
fi
echo "==> Installing Node dependencies…"
PACKAGE_MANAGER=$(command -v yarn >/dev/null 2>&1 && echo "yarn" || echo "npm")
$PACKAGE_MANAGER install

51
packages/sdk/scripts/build Executable file
View File

@@ -0,0 +1,51 @@
#!/usr/bin/env bash
set -exuo pipefail
cd "$(dirname "$0")/.."
node scripts/utils/check-version.cjs
# Build into dist and will publish the package from there,
# so that src/resources/foo.ts becomes <package root>/resources/foo.js
# This way importing from `"@opencode-ai/sdk/resources/foo"` works
# even with `"moduleResolution": "node"`
rm -rf dist; mkdir dist
# Copy src to dist/src and build from dist/src into dist, so that
# the source map for index.js.map will refer to ./src/index.ts etc
cp -rp src README.md dist
for file in LICENSE CHANGELOG.md; do
if [ -e "${file}" ]; then cp "${file}" dist; fi
done
if [ -e "bin/cli" ]; then
mkdir -p dist/bin
cp -p "bin/cli" dist/bin/;
fi
if [ -e "bin/migration-config.json" ]; then
mkdir -p dist/bin
cp -p "bin/migration-config.json" dist/bin/;
fi
# this converts the export map paths for the dist directory
# and does a few other minor things
node scripts/utils/make-dist-package-json.cjs > dist/package.json
# build to .js/.mjs/.d.ts files
./node_modules/.bin/tsc-multi
# we need to patch index.js so that `new module.exports()` works for cjs backwards
# compat. No way to get that from index.ts because it would cause compile errors
# when building .mjs
node scripts/utils/fix-index-exports.cjs
cp tsconfig.dist-src.json dist/src/tsconfig.json
node scripts/utils/postprocess-files.cjs
# make sure that nothing crashes when we require the output CJS or
# import the output ESM
(cd dist && node -e 'require("@opencode-ai/sdk")')
(cd dist && node -e 'import("@opencode-ai/sdk")' --input-type=module)
if [ -e ./scripts/build-deno ]
then
./scripts/build-deno
fi

12
packages/sdk/scripts/format Executable file
View File

@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -e
cd "$(dirname "$0")/.."
echo "==> Running eslint --fix"
./node_modules/.bin/eslint --fix .
echo "==> Running prettier --write"
# format things eslint didn't
./node_modules/.bin/prettier --write --cache --cache-strategy metadata . '!**/dist' '!**/*.ts' '!**/*.mts' '!**/*.cts' '!**/*.js' '!**/*.mjs' '!**/*.cjs'

21
packages/sdk/scripts/lint Executable file
View File

@@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -e
cd "$(dirname "$0")/.."
echo "==> Running eslint"
./node_modules/.bin/eslint .
echo "==> Building"
./scripts/build
echo "==> Checking types"
./node_modules/typescript/bin/tsc
echo "==> Running Are The Types Wrong?"
./node_modules/.bin/attw --pack dist -f json >.attw.json || true
node scripts/utils/attw-report.cjs
echo "==> Running publint"
./node_modules/.bin/publint dist

41
packages/sdk/scripts/mock Executable file
View File

@@ -0,0 +1,41 @@
#!/usr/bin/env bash
set -e
cd "$(dirname "$0")/.."
if [[ -n "$1" && "$1" != '--'* ]]; then
URL="$1"
shift
else
URL="$(grep 'openapi_spec_url' .stats.yml | cut -d' ' -f2)"
fi
# Check if the URL is empty
if [ -z "$URL" ]; then
echo "Error: No OpenAPI spec path/url provided or found in .stats.yml"
exit 1
fi
echo "==> Starting mock server with URL ${URL}"
# Run prism mock on the given spec
if [ "$1" == "--daemon" ]; then
npm exec --package=@stainless-api/prism-cli@5.8.5 -- prism mock "$URL" &> .prism.log &
# Wait for server to come online
echo -n "Waiting for server"
while ! grep -q "✖ fatal\|Prism is listening" ".prism.log" ; do
echo -n "."
sleep 0.1
done
if grep -q "✖ fatal" ".prism.log"; then
cat .prism.log
exit 1
fi
echo
else
npm exec --package=@stainless-api/prism-cli@5.8.5 -- prism mock "$URL"
fi

56
packages/sdk/scripts/test Executable file
View File

@@ -0,0 +1,56 @@
#!/usr/bin/env bash
set -e
cd "$(dirname "$0")/.."
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
NC='\033[0m' # No Color
function prism_is_running() {
curl --silent "http://localhost:4010" >/dev/null 2>&1
}
kill_server_on_port() {
pids=$(lsof -t -i tcp:"$1" || echo "")
if [ "$pids" != "" ]; then
kill "$pids"
echo "Stopped $pids."
fi
}
function is_overriding_api_base_url() {
[ -n "$TEST_API_BASE_URL" ]
}
if ! is_overriding_api_base_url && ! prism_is_running ; then
# When we exit this script, make sure to kill the background mock server process
trap 'kill_server_on_port 4010' EXIT
# Start the dev server
./scripts/mock --daemon
fi
if is_overriding_api_base_url ; then
echo -e "${GREEN}✔ Running tests against ${TEST_API_BASE_URL}${NC}"
echo
elif ! prism_is_running ; then
echo -e "${RED}ERROR:${NC} The test suite will not run without a mock Prism server"
echo -e "running against your OpenAPI spec."
echo
echo -e "To run the server, pass in the path or url of your OpenAPI"
echo -e "spec to the prism command:"
echo
echo -e " \$ ${YELLOW}npm exec --package=@stoplight/prism-cli@~5.3.2 -- prism mock path/to/your.openapi.yml${NC}"
echo
exit 1
else
echo -e "${GREEN}✔ Mock prism server is running with your OpenAPI spec${NC}"
echo
fi
echo "==> Running tests"
./node_modules/.bin/jest "$@"

View File

@@ -0,0 +1,24 @@
const fs = require('fs');
const problems = Object.values(JSON.parse(fs.readFileSync('.attw.json', 'utf-8')).problems)
.flat()
.filter(
(problem) =>
!(
// This is intentional, if the user specifies .mjs they get ESM.
(
(problem.kind === 'CJSResolvesToESM' && problem.entrypoint.endsWith('.mjs')) ||
// This is intentional for backwards compat reasons.
(problem.kind === 'MissingExportEquals' && problem.implementationFileName.endsWith('/index.js')) ||
// this is intentional, we deliberately attempt to import types that may not exist from parent node_modules
// folders to better support various runtimes without triggering automatic type acquisition.
(problem.kind === 'InternalResolutionError' && problem.moduleSpecifier.includes('node_modules'))
)
),
);
fs.unlinkSync('.attw.json');
if (problems.length) {
process.stdout.write('The types are wrong!\n' + JSON.stringify(problems, null, 2) + '\n');
process.exitCode = 1;
} else {
process.stdout.write('Types ok!\n');
}

View File

@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# Check if you happen to call prepare for a repository that's already in node_modules.
[ "$(basename "$(dirname "$PWD")")" = 'node_modules' ] ||
# The name of the containing directory that 'npm` uses, which looks like
# $HOME/.npm/_cacache/git-cloneXXXXXX
[ "$(basename "$(dirname "$PWD")")" = 'tmp' ] ||
# The name of the containing directory that 'yarn` uses, which looks like
# $(yarn cache dir)/.tmp/XXXXX
[ "$(basename "$(dirname "$PWD")")" = '.tmp' ]

View File

@@ -0,0 +1,20 @@
const fs = require('fs');
const path = require('path');
const main = () => {
const pkg = require('../../package.json');
const version = pkg['version'];
if (!version) throw 'The version property is not set in the package.json file';
if (typeof version !== 'string') {
throw `Unexpected type for the package.json version field; got ${typeof version}, expected string`;
}
const versionFile = path.resolve(__dirname, '..', '..', 'src', 'version.ts');
const contents = fs.readFileSync(versionFile, 'utf8');
const output = contents.replace(/(export const VERSION = ')(.*)(')/g, `$1${version}$3`);
fs.writeFileSync(versionFile, output);
};
if (require.main === module) {
main();
}

View File

@@ -0,0 +1,17 @@
const fs = require('fs');
const path = require('path');
const indexJs =
process.env['DIST_PATH'] ?
path.resolve(process.env['DIST_PATH'], 'index.js')
: path.resolve(__dirname, '..', '..', 'dist', 'index.js');
let before = fs.readFileSync(indexJs, 'utf8');
let after = before.replace(
/^(\s*Object\.defineProperty\s*\(exports,\s*["']__esModule["'].+)$/m,
`exports = module.exports = function (...args) {
return new exports.default(...args)
}
$1`.replace(/^ /gm, ''),
);
fs.writeFileSync(indexJs, after, 'utf8');

View File

@@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -exuo pipefail
# the package is published to NPM from ./dist
# we want the final file structure for git installs to match the npm installs, so we
# delete everything except ./dist and ./node_modules
find . -maxdepth 1 -mindepth 1 ! -name 'dist' ! -name 'node_modules' -exec rm -rf '{}' +
# move everything from ./dist to .
mv dist/* .
# delete the now-empty ./dist
rmdir dist

View File

@@ -0,0 +1,21 @@
const pkgJson = require(process.env['PKG_JSON_PATH'] || '../../package.json');
function processExportMap(m) {
for (const key in m) {
const value = m[key];
if (typeof value === 'string') m[key] = value.replace(/^\.\/dist\//, './');
else processExportMap(value);
}
}
processExportMap(pkgJson.exports);
for (const key of ['types', 'main', 'module']) {
if (typeof pkgJson[key] === 'string') pkgJson[key] = pkgJson[key].replace(/^(\.\/)?dist\//, './');
}
delete pkgJson.devDependencies;
delete pkgJson.scripts.prepack;
delete pkgJson.scripts.prepublishOnly;
delete pkgJson.scripts.prepare;
console.log(JSON.stringify(pkgJson, null, 2));

View File

@@ -0,0 +1,94 @@
// @ts-check
const fs = require('fs');
const path = require('path');
const distDir =
process.env['DIST_PATH'] ?
path.resolve(process.env['DIST_PATH'])
: path.resolve(__dirname, '..', '..', 'dist');
async function* walk(dir) {
for await (const d of await fs.promises.opendir(dir)) {
const entry = path.join(dir, d.name);
if (d.isDirectory()) yield* walk(entry);
else if (d.isFile()) yield entry;
}
}
async function postprocess() {
for await (const file of walk(distDir)) {
if (!/(\.d)?[cm]?ts$/.test(file)) continue;
const code = await fs.promises.readFile(file, 'utf8');
// strip out lib="dom", types="node", and types="react" references; these
// are needed at build time, but would pollute the user's TS environment
const transformed = code.replace(
/^ *\/\/\/ *<reference +(lib="dom"|types="(node|react)").*?\n/gm,
// replace with same number of characters to avoid breaking source maps
(match) => ' '.repeat(match.length - 1) + '\n',
);
if (transformed !== code) {
console.error(`wrote ${path.relative(process.cwd(), file)}`);
await fs.promises.writeFile(file, transformed, 'utf8');
}
}
const newExports = {
'.': {
require: {
types: './index.d.ts',
default: './index.js',
},
types: './index.d.mts',
default: './index.mjs',
},
};
for (const entry of await fs.promises.readdir(distDir, { withFileTypes: true })) {
if (entry.isDirectory() && entry.name !== 'src' && entry.name !== 'internal' && entry.name !== 'bin') {
const subpath = './' + entry.name;
newExports[subpath + '/*.mjs'] = {
default: subpath + '/*.mjs',
};
newExports[subpath + '/*.js'] = {
default: subpath + '/*.js',
};
newExports[subpath + '/*'] = {
import: subpath + '/*.mjs',
require: subpath + '/*.js',
};
} else if (entry.isFile() && /\.[cm]?js$/.test(entry.name)) {
const { name, ext } = path.parse(entry.name);
const subpathWithoutExt = './' + name;
const subpath = './' + entry.name;
newExports[subpathWithoutExt] ||= { import: undefined, require: undefined };
const isModule = ext[1] === 'm';
if (isModule) {
newExports[subpathWithoutExt].import = subpath;
} else {
newExports[subpathWithoutExt].require = subpath;
}
newExports[subpath] = {
default: subpath,
};
}
}
await fs.promises.writeFile(
'dist/package.json',
JSON.stringify(
Object.assign(
/** @type {Record<String, unknown>} */ (
JSON.parse(await fs.promises.readFile('dist/package.json', 'utf-8'))
),
{
exports: newExports,
},
),
null,
2,
),
);
}
postprocess();

View File

@@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -exuo pipefail
RESPONSE=$(curl -X POST "$URL" \
-H "Authorization: Bearer $AUTH" \
-H "Content-Type: application/json")
SIGNED_URL=$(echo "$RESPONSE" | jq -r '.url')
if [[ "$SIGNED_URL" == "null" ]]; then
echo -e "\033[31mFailed to get signed URL.\033[0m"
exit 1
fi
UPLOAD_RESPONSE=$(tar -cz dist | curl -v -X PUT \
-H "Content-Type: application/gzip" \
--data-binary @- "$SIGNED_URL" 2>&1)
if echo "$UPLOAD_RESPONSE" | grep -q "HTTP/[0-9.]* 200"; then
echo -e "\033[32mUploaded build to Stainless storage.\033[0m"
echo -e "\033[32mInstallation: npm install 'https://pkg.stainless.com/s/opencode-typescript/$SHA'\033[0m"
else
echo -e "\033[31mFailed to upload artifact.\033[0m"
exit 1
fi

View File

@@ -0,0 +1,2 @@
/** @deprecated Import from ./core/api-promise instead */
export * from './core/api-promise';

864
packages/sdk/src/client.ts Normal file
View File

@@ -0,0 +1,864 @@
// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
import type { RequestInit, RequestInfo, BodyInit } from './internal/builtin-types';
import type { HTTPMethod, PromiseOrValue, MergedRequestInit, FinalizedRequestInit } from './internal/types';
import { uuid4 } from './internal/utils/uuid';
import { validatePositiveInteger, isAbsoluteURL, safeJSON } from './internal/utils/values';
import { sleep } from './internal/utils/sleep';
export type { Logger, LogLevel } from './internal/utils/log';
import { castToError, isAbortError } from './internal/errors';
import type { APIResponseProps } from './internal/parse';
import { getPlatformHeaders } from './internal/detect-platform';
import * as Shims from './internal/shims';
import * as Opts from './internal/request-options';
import { VERSION } from './version';
import * as Errors from './core/error';
import * as Uploads from './core/uploads';
import * as API from './resources/index';
import { APIPromise } from './core/api-promise';
import {
App,
AppInitResponse,
AppLogParams,
AppLogResponse,
AppModesResponse,
AppProvidersResponse,
AppResource,
Mode,
Model,
Provider,
} from './resources/app';
import {
Config,
ConfigResource,
KeybindsConfig,
McpLocalConfig,
McpRemoteConfig,
ModeConfig,
} from './resources/config';
import { Event, EventListResponse } from './resources/event';
import { File, FileReadParams, FileReadResponse, FileResource, FileStatusResponse } from './resources/file';
import {
Find,
FindFilesParams,
FindFilesResponse,
FindSymbolsParams,
FindSymbolsResponse,
FindTextParams,
FindTextResponse,
Match,
Symbol,
} from './resources/find';
import {
AssistantMessage,
FilePart,
FilePartInput,
FilePartSource,
FilePartSourceText,
FileSource,
Message,
Part,
Session,
SessionAbortResponse,
SessionChatParams,
SessionDeleteResponse,
SessionInitParams,
SessionInitResponse,
SessionListResponse,
SessionMessagesResponse,
SessionResource,
SessionRevertParams,
SessionSummarizeParams,
SessionSummarizeResponse,
SnapshotPart,
StepFinishPart,
StepStartPart,
SymbolSource,
TextPart,
TextPartInput,
ToolPart,
ToolStateCompleted,
ToolStateError,
ToolStatePending,
ToolStateRunning,
UserMessage,
} from './resources/session';
import { Tui, TuiAppendPromptParams, TuiAppendPromptResponse, TuiOpenHelpResponse } from './resources/tui';
import { type Fetch } from './internal/builtin-types';
import { HeadersLike, NullableHeaders, buildHeaders } from './internal/headers';
import { FinalRequestOptions, RequestOptions } from './internal/request-options';
import { readEnv } from './internal/utils/env';
import {
type LogLevel,
type Logger,
formatRequestDetails,
loggerFor,
parseLogLevel,
} from './internal/utils/log';
import { isEmptyObj } from './internal/utils/values';
export interface ClientOptions {
/**
* Override the default base URL for the API, e.g., "https://api.example.com/v2/"
*
* Defaults to process.env['OPENCODE_BASE_URL'].
*/
baseURL?: string | null | undefined;
/**
* The maximum amount of time (in milliseconds) that the client should wait for a response
* from the server before timing out a single request.
*
* Note that request timeouts are retried by default, so in a worst-case scenario you may wait
* much longer than this timeout before the promise succeeds or fails.
*
* @unit milliseconds
*/
timeout?: number | undefined;
/**
* Additional `RequestInit` options to be passed to `fetch` calls.
* Properties will be overridden by per-request `fetchOptions`.
*/
fetchOptions?: MergedRequestInit | undefined;
/**
* Specify a custom `fetch` function implementation.
*
* If not provided, we expect that `fetch` is defined globally.
*/
fetch?: Fetch | undefined;
/**
* The maximum number of times that the client will retry a request in case of a
* temporary failure, like a network error or a 5XX error from the server.
*
* @default 2
*/
maxRetries?: number | undefined;
/**
* Default headers to include with every request to the API.
*
* These can be removed in individual requests by explicitly setting the
* header to `null` in request options.
*/
defaultHeaders?: HeadersLike | undefined;
/**
* Default query parameters to include with every request to the API.
*
* These can be removed in individual requests by explicitly setting the
* param to `undefined` in request options.
*/
defaultQuery?: Record<string, string | undefined> | undefined;
/**
* Set the log level.
*
* Defaults to process.env['OPENCODE_LOG'] or 'warn' if it isn't set.
*/
logLevel?: LogLevel | undefined;
/**
* Set the logger.
*
* Defaults to globalThis.console.
*/
logger?: Logger | undefined;
}
/**
* API Client for interfacing with the Opencode API.
*/
export class Opencode {
baseURL: string;
maxRetries: number;
timeout: number;
logger: Logger | undefined;
logLevel: LogLevel | undefined;
fetchOptions: MergedRequestInit | undefined;
private fetch: Fetch;
#encoder: Opts.RequestEncoder;
protected idempotencyHeader?: string;
private _options: ClientOptions;
/**
* API Client for interfacing with the Opencode API.
*
* @param {string} [opts.baseURL=process.env['OPENCODE_BASE_URL'] ?? http://localhost:54321] - Override the default base URL for the API.
* @param {number} [opts.timeout=1 minute] - The maximum amount of time (in milliseconds) the client will wait for a response before timing out.
* @param {MergedRequestInit} [opts.fetchOptions] - Additional `RequestInit` options to be passed to `fetch` calls.
* @param {Fetch} [opts.fetch] - Specify a custom `fetch` function implementation.
* @param {number} [opts.maxRetries=2] - The maximum number of times the client will retry a request.
* @param {HeadersLike} opts.defaultHeaders - Default headers to include with every request to the API.
* @param {Record<string, string | undefined>} opts.defaultQuery - Default query parameters to include with every request to the API.
*/
constructor({ baseURL = readEnv('OPENCODE_BASE_URL'), ...opts }: ClientOptions = {}) {
const options: ClientOptions = {
...opts,
baseURL: baseURL || `http://localhost:54321`,
};
this.baseURL = options.baseURL!;
this.timeout = options.timeout ?? Opencode.DEFAULT_TIMEOUT /* 1 minute */;
this.logger = options.logger ?? console;
const defaultLogLevel = 'warn';
// Set default logLevel early so that we can log a warning in parseLogLevel.
this.logLevel = defaultLogLevel;
this.logLevel =
parseLogLevel(options.logLevel, 'ClientOptions.logLevel', this) ??
parseLogLevel(readEnv('OPENCODE_LOG'), "process.env['OPENCODE_LOG']", this) ??
defaultLogLevel;
this.fetchOptions = options.fetchOptions;
this.maxRetries = options.maxRetries ?? 2;
this.fetch = options.fetch ?? Shims.getDefaultFetch();
this.#encoder = Opts.FallbackEncoder;
this._options = options;
}
/**
* Create a new client instance re-using the same options given to the current client with optional overriding.
*/
withOptions(options: Partial<ClientOptions>): this {
const client = new (this.constructor as any as new (props: ClientOptions) => typeof this)({
...this._options,
baseURL: this.baseURL,
maxRetries: this.maxRetries,
timeout: this.timeout,
logger: this.logger,
logLevel: this.logLevel,
fetch: this.fetch,
fetchOptions: this.fetchOptions,
...options,
});
return client;
}
/**
* Check whether the base URL is set to its default.
*/
#baseURLOverridden(): boolean {
return this.baseURL !== 'http://localhost:54321';
}
protected defaultQuery(): Record<string, string | undefined> | undefined {
return this._options.defaultQuery;
}
protected validateHeaders({ values, nulls }: NullableHeaders) {
return;
}
/**
* Basic re-implementation of `qs.stringify` for primitive types.
*/
protected stringifyQuery(query: Record<string, unknown>): string {
return Object.entries(query)
.filter(([_, value]) => typeof value !== 'undefined')
.map(([key, value]) => {
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
}
if (value === null) {
return `${encodeURIComponent(key)}=`;
}
throw new Errors.OpencodeError(
`Cannot stringify type ${typeof value}; Expected string, number, boolean, or null. If you need to pass nested query parameters, you can manually encode them, e.g. { query: { 'foo[key1]': value1, 'foo[key2]': value2 } }, and please open a GitHub issue requesting better support for your use case.`,
);
})
.join('&');
}
private getUserAgent(): string {
return `${this.constructor.name}/JS ${VERSION}`;
}
protected defaultIdempotencyKey(): string {
return `stainless-node-retry-${uuid4()}`;
}
protected makeStatusError(
status: number,
error: Object,
message: string | undefined,
headers: Headers,
): Errors.APIError {
return Errors.APIError.generate(status, error, message, headers);
}
buildURL(
path: string,
query: Record<string, unknown> | null | undefined,
defaultBaseURL?: string | undefined,
): string {
const baseURL = (!this.#baseURLOverridden() && defaultBaseURL) || this.baseURL;
const url =
isAbsoluteURL(path) ?
new URL(path)
: new URL(baseURL + (baseURL.endsWith('/') && path.startsWith('/') ? path.slice(1) : path));
const defaultQuery = this.defaultQuery();
if (!isEmptyObj(defaultQuery)) {
query = { ...defaultQuery, ...query };
}
if (typeof query === 'object' && query && !Array.isArray(query)) {
url.search = this.stringifyQuery(query as Record<string, unknown>);
}
return url.toString();
}
/**
* Used as a callback for mutating the given `FinalRequestOptions` object.
*/
protected async prepareOptions(options: FinalRequestOptions): Promise<void> {}
/**
* Used as a callback for mutating the given `RequestInit` object.
*
* This is useful for cases where you want to add certain headers based off of
* the request properties, e.g. `method` or `url`.
*/
protected async prepareRequest(
request: RequestInit,
{ url, options }: { url: string; options: FinalRequestOptions },
): Promise<void> {}
get<Rsp>(path: string, opts?: PromiseOrValue<RequestOptions>): APIPromise<Rsp> {
return this.methodRequest('get', path, opts);
}
post<Rsp>(path: string, opts?: PromiseOrValue<RequestOptions>): APIPromise<Rsp> {
return this.methodRequest('post', path, opts);
}
patch<Rsp>(path: string, opts?: PromiseOrValue<RequestOptions>): APIPromise<Rsp> {
return this.methodRequest('patch', path, opts);
}
put<Rsp>(path: string, opts?: PromiseOrValue<RequestOptions>): APIPromise<Rsp> {
return this.methodRequest('put', path, opts);
}
delete<Rsp>(path: string, opts?: PromiseOrValue<RequestOptions>): APIPromise<Rsp> {
return this.methodRequest('delete', path, opts);
}
private methodRequest<Rsp>(
method: HTTPMethod,
path: string,
opts?: PromiseOrValue<RequestOptions>,
): APIPromise<Rsp> {
return this.request(
Promise.resolve(opts).then((opts) => {
return { method, path, ...opts };
}),
);
}
request<Rsp>(
options: PromiseOrValue<FinalRequestOptions>,
remainingRetries: number | null = null,
): APIPromise<Rsp> {
return new APIPromise(this, this.makeRequest(options, remainingRetries, undefined));
}
private async makeRequest(
optionsInput: PromiseOrValue<FinalRequestOptions>,
retriesRemaining: number | null,
retryOfRequestLogID: string | undefined,
): Promise<APIResponseProps> {
const options = await optionsInput;
const maxRetries = options.maxRetries ?? this.maxRetries;
if (retriesRemaining == null) {
retriesRemaining = maxRetries;
}
await this.prepareOptions(options);
const { req, url, timeout } = await this.buildRequest(options, {
retryCount: maxRetries - retriesRemaining,
});
await this.prepareRequest(req, { url, options });
/** Not an API request ID, just for correlating local log entries. */
const requestLogID = 'log_' + ((Math.random() * (1 << 24)) | 0).toString(16).padStart(6, '0');
const retryLogStr = retryOfRequestLogID === undefined ? '' : `, retryOf: ${retryOfRequestLogID}`;
const startTime = Date.now();
loggerFor(this).debug(
`[${requestLogID}] sending request`,
formatRequestDetails({
retryOfRequestLogID,
method: options.method,
url,
options,
headers: req.headers,
}),
);
if (options.signal?.aborted) {
throw new Errors.APIUserAbortError();
}
const controller = new AbortController();
const response = await this.fetchWithTimeout(url, req, timeout, controller).catch(castToError);
const headersTime = Date.now();
if (response instanceof Error) {
const retryMessage = `retrying, ${retriesRemaining} attempts remaining`;
if (options.signal?.aborted) {
throw new Errors.APIUserAbortError();
}
// detect native connection timeout errors
// deno throws "TypeError: error sending request for url (https://example/): client error (Connect): tcp connect error: Operation timed out (os error 60): Operation timed out (os error 60)"
// undici throws "TypeError: fetch failed" with cause "ConnectTimeoutError: Connect Timeout Error (attempted address: example:443, timeout: 1ms)"
// others do not provide enough information to distinguish timeouts from other connection errors
const isTimeout =
isAbortError(response) ||
/timed? ?out/i.test(String(response) + ('cause' in response ? String(response.cause) : ''));
if (retriesRemaining) {
loggerFor(this).info(
`[${requestLogID}] connection ${isTimeout ? 'timed out' : 'failed'} - ${retryMessage}`,
);
loggerFor(this).debug(
`[${requestLogID}] connection ${isTimeout ? 'timed out' : 'failed'} (${retryMessage})`,
formatRequestDetails({
retryOfRequestLogID,
url,
durationMs: headersTime - startTime,
message: response.message,
}),
);
return this.retryRequest(options, retriesRemaining, retryOfRequestLogID ?? requestLogID);
}
loggerFor(this).info(
`[${requestLogID}] connection ${isTimeout ? 'timed out' : 'failed'} - error; no more retries left`,
);
loggerFor(this).debug(
`[${requestLogID}] connection ${isTimeout ? 'timed out' : 'failed'} (error; no more retries left)`,
formatRequestDetails({
retryOfRequestLogID,
url,
durationMs: headersTime - startTime,
message: response.message,
}),
);
if (isTimeout) {
throw new Errors.APIConnectionTimeoutError();
}
throw new Errors.APIConnectionError({ cause: response });
}
const responseInfo = `[${requestLogID}${retryLogStr}] ${req.method} ${url} ${
response.ok ? 'succeeded' : 'failed'
} with status ${response.status} in ${headersTime - startTime}ms`;
if (!response.ok) {
const shouldRetry = await this.shouldRetry(response);
if (retriesRemaining && shouldRetry) {
const retryMessage = `retrying, ${retriesRemaining} attempts remaining`;
// We don't need the body of this response.
await Shims.CancelReadableStream(response.body);
loggerFor(this).info(`${responseInfo} - ${retryMessage}`);
loggerFor(this).debug(
`[${requestLogID}] response error (${retryMessage})`,
formatRequestDetails({
retryOfRequestLogID,
url: response.url,
status: response.status,
headers: response.headers,
durationMs: headersTime - startTime,
}),
);
return this.retryRequest(
options,
retriesRemaining,
retryOfRequestLogID ?? requestLogID,
response.headers,
);
}
const retryMessage = shouldRetry ? `error; no more retries left` : `error; not retryable`;
loggerFor(this).info(`${responseInfo} - ${retryMessage}`);
const errText = await response.text().catch((err: any) => castToError(err).message);
const errJSON = safeJSON(errText);
const errMessage = errJSON ? undefined : errText;
loggerFor(this).debug(
`[${requestLogID}] response error (${retryMessage})`,
formatRequestDetails({
retryOfRequestLogID,
url: response.url,
status: response.status,
headers: response.headers,
message: errMessage,
durationMs: Date.now() - startTime,
}),
);
const err = this.makeStatusError(response.status, errJSON, errMessage, response.headers);
throw err;
}
loggerFor(this).info(responseInfo);
loggerFor(this).debug(
`[${requestLogID}] response start`,
formatRequestDetails({
retryOfRequestLogID,
url: response.url,
status: response.status,
headers: response.headers,
durationMs: headersTime - startTime,
}),
);
return { response, options, controller, requestLogID, retryOfRequestLogID, startTime };
}
async fetchWithTimeout(
url: RequestInfo,
init: RequestInit | undefined,
ms: number,
controller: AbortController,
): Promise<Response> {
const { signal, method, ...options } = init || {};
if (signal) signal.addEventListener('abort', () => controller.abort());
const timeout = setTimeout(() => controller.abort(), ms);
const isReadableBody =
((globalThis as any).ReadableStream && options.body instanceof (globalThis as any).ReadableStream) ||
(typeof options.body === 'object' && options.body !== null && Symbol.asyncIterator in options.body);
const fetchOptions: RequestInit = {
signal: controller.signal as any,
...(isReadableBody ? { duplex: 'half' } : {}),
method: 'GET',
...options,
};
if (method) {
// Custom methods like 'patch' need to be uppercased
// See https://github.com/nodejs/undici/issues/2294
fetchOptions.method = method.toUpperCase();
}
try {
// use undefined this binding; fetch errors if bound to something else in browser/cloudflare
return await this.fetch.call(undefined, url, fetchOptions);
} finally {
clearTimeout(timeout);
}
}
private async shouldRetry(response: Response): Promise<boolean> {
// Note this is not a standard header.
const shouldRetryHeader = response.headers.get('x-should-retry');
// If the server explicitly says whether or not to retry, obey.
if (shouldRetryHeader === 'true') return true;
if (shouldRetryHeader === 'false') return false;
// Retry on request timeouts.
if (response.status === 408) return true;
// Retry on lock timeouts.
if (response.status === 409) return true;
// Retry on rate limits.
if (response.status === 429) return true;
// Retry internal errors.
if (response.status >= 500) return true;
return false;
}
private async retryRequest(
options: FinalRequestOptions,
retriesRemaining: number,
requestLogID: string,
responseHeaders?: Headers | undefined,
): Promise<APIResponseProps> {
let timeoutMillis: number | undefined;
// Note the `retry-after-ms` header may not be standard, but is a good idea and we'd like proactive support for it.
const retryAfterMillisHeader = responseHeaders?.get('retry-after-ms');
if (retryAfterMillisHeader) {
const timeoutMs = parseFloat(retryAfterMillisHeader);
if (!Number.isNaN(timeoutMs)) {
timeoutMillis = timeoutMs;
}
}
// About the Retry-After header: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After
const retryAfterHeader = responseHeaders?.get('retry-after');
if (retryAfterHeader && !timeoutMillis) {
const timeoutSeconds = parseFloat(retryAfterHeader);
if (!Number.isNaN(timeoutSeconds)) {
timeoutMillis = timeoutSeconds * 1000;
} else {
timeoutMillis = Date.parse(retryAfterHeader) - Date.now();
}
}
// If the API asks us to wait a certain amount of time (and it's a reasonable amount),
// just do what it says, but otherwise calculate a default
if (!(timeoutMillis && 0 <= timeoutMillis && timeoutMillis < 60 * 1000)) {
const maxRetries = options.maxRetries ?? this.maxRetries;
timeoutMillis = this.calculateDefaultRetryTimeoutMillis(retriesRemaining, maxRetries);
}
await sleep(timeoutMillis);
return this.makeRequest(options, retriesRemaining - 1, requestLogID);
}
private calculateDefaultRetryTimeoutMillis(retriesRemaining: number, maxRetries: number): number {
const initialRetryDelay = 0.5;
const maxRetryDelay = 8.0;
const numRetries = maxRetries - retriesRemaining;
// Apply exponential backoff, but not more than the max.
const sleepSeconds = Math.min(initialRetryDelay * Math.pow(2, numRetries), maxRetryDelay);
// Apply some jitter, take up to at most 25 percent of the retry time.
const jitter = 1 - Math.random() * 0.25;
return sleepSeconds * jitter * 1000;
}
async buildRequest(
inputOptions: FinalRequestOptions,
{ retryCount = 0 }: { retryCount?: number } = {},
): Promise<{ req: FinalizedRequestInit; url: string; timeout: number }> {
const options = { ...inputOptions };
const { method, path, query, defaultBaseURL } = options;
const url = this.buildURL(path!, query as Record<string, unknown>, defaultBaseURL);
if ('timeout' in options) validatePositiveInteger('timeout', options.timeout);
options.timeout = options.timeout ?? this.timeout;
const { bodyHeaders, body } = this.buildBody({ options });
const reqHeaders = await this.buildHeaders({ options: inputOptions, method, bodyHeaders, retryCount });
const req: FinalizedRequestInit = {
method,
headers: reqHeaders,
...(options.signal && { signal: options.signal }),
...((globalThis as any).ReadableStream &&
body instanceof (globalThis as any).ReadableStream && { duplex: 'half' }),
...(body && { body }),
...((this.fetchOptions as any) ?? {}),
...((options.fetchOptions as any) ?? {}),
};
return { req, url, timeout: options.timeout };
}
private async buildHeaders({
options,
method,
bodyHeaders,
retryCount,
}: {
options: FinalRequestOptions;
method: HTTPMethod;
bodyHeaders: HeadersLike;
retryCount: number;
}): Promise<Headers> {
let idempotencyHeaders: HeadersLike = {};
if (this.idempotencyHeader && method !== 'get') {
if (!options.idempotencyKey) options.idempotencyKey = this.defaultIdempotencyKey();
idempotencyHeaders[this.idempotencyHeader] = options.idempotencyKey;
}
const headers = buildHeaders([
idempotencyHeaders,
{
Accept: 'application/json',
'User-Agent': this.getUserAgent(),
'X-Stainless-Retry-Count': String(retryCount),
...(options.timeout ? { 'X-Stainless-Timeout': String(Math.trunc(options.timeout / 1000)) } : {}),
...getPlatformHeaders(),
},
this._options.defaultHeaders,
bodyHeaders,
options.headers,
]);
this.validateHeaders(headers);
return headers.values;
}
private buildBody({ options: { body, headers: rawHeaders } }: { options: FinalRequestOptions }): {
bodyHeaders: HeadersLike;
body: BodyInit | undefined;
} {
if (!body) {
return { bodyHeaders: undefined, body: undefined };
}
const headers = buildHeaders([rawHeaders]);
if (
// Pass raw type verbatim
ArrayBuffer.isView(body) ||
body instanceof ArrayBuffer ||
body instanceof DataView ||
(typeof body === 'string' &&
// Preserve legacy string encoding behavior for now
headers.values.has('content-type')) ||
// `Blob` is superset of `File`
body instanceof Blob ||
// `FormData` -> `multipart/form-data`
body instanceof FormData ||
// `URLSearchParams` -> `application/x-www-form-urlencoded`
body instanceof URLSearchParams ||
// Send chunked stream (each chunk has own `length`)
((globalThis as any).ReadableStream && body instanceof (globalThis as any).ReadableStream)
) {
return { bodyHeaders: undefined, body: body as BodyInit };
} else if (
typeof body === 'object' &&
(Symbol.asyncIterator in body ||
(Symbol.iterator in body && 'next' in body && typeof body.next === 'function'))
) {
return { bodyHeaders: undefined, body: Shims.ReadableStreamFrom(body as AsyncIterable<Uint8Array>) };
} else {
return this.#encoder({ body, headers });
}
}
static Opencode = this;
static DEFAULT_TIMEOUT = 60000; // 1 minute
static OpencodeError = Errors.OpencodeError;
static APIError = Errors.APIError;
static APIConnectionError = Errors.APIConnectionError;
static APIConnectionTimeoutError = Errors.APIConnectionTimeoutError;
static APIUserAbortError = Errors.APIUserAbortError;
static NotFoundError = Errors.NotFoundError;
static ConflictError = Errors.ConflictError;
static RateLimitError = Errors.RateLimitError;
static BadRequestError = Errors.BadRequestError;
static AuthenticationError = Errors.AuthenticationError;
static InternalServerError = Errors.InternalServerError;
static PermissionDeniedError = Errors.PermissionDeniedError;
static UnprocessableEntityError = Errors.UnprocessableEntityError;
static toFile = Uploads.toFile;
event: API.Event = new API.Event(this);
app: API.AppResource = new API.AppResource(this);
find: API.Find = new API.Find(this);
file: API.FileResource = new API.FileResource(this);
config: API.ConfigResource = new API.ConfigResource(this);
session: API.SessionResource = new API.SessionResource(this);
tui: API.Tui = new API.Tui(this);
}
Opencode.Event = Event;
Opencode.AppResource = AppResource;
Opencode.Find = Find;
Opencode.FileResource = FileResource;
Opencode.ConfigResource = ConfigResource;
Opencode.SessionResource = SessionResource;
Opencode.Tui = Tui;
export declare namespace Opencode {
export type RequestOptions = Opts.RequestOptions;
export { Event as Event, type EventListResponse as EventListResponse };
export {
AppResource as AppResource,
type App as App,
type Mode as Mode,
type Model as Model,
type Provider as Provider,
type AppInitResponse as AppInitResponse,
type AppLogResponse as AppLogResponse,
type AppModesResponse as AppModesResponse,
type AppProvidersResponse as AppProvidersResponse,
type AppLogParams as AppLogParams,
};
export {
Find as Find,
type Match as Match,
type Symbol as Symbol,
type FindFilesResponse as FindFilesResponse,
type FindSymbolsResponse as FindSymbolsResponse,
type FindTextResponse as FindTextResponse,
type FindFilesParams as FindFilesParams,
type FindSymbolsParams as FindSymbolsParams,
type FindTextParams as FindTextParams,
};
export {
FileResource as FileResource,
type File as File,
type FileReadResponse as FileReadResponse,
type FileStatusResponse as FileStatusResponse,
type FileReadParams as FileReadParams,
};
export {
ConfigResource as ConfigResource,
type Config as Config,
type KeybindsConfig as KeybindsConfig,
type McpLocalConfig as McpLocalConfig,
type McpRemoteConfig as McpRemoteConfig,
type ModeConfig as ModeConfig,
};
export {
SessionResource as SessionResource,
type AssistantMessage as AssistantMessage,
type FilePart as FilePart,
type FilePartInput as FilePartInput,
type FilePartSource as FilePartSource,
type FilePartSourceText as FilePartSourceText,
type FileSource as FileSource,
type Message as Message,
type Part as Part,
type Session as Session,
type SnapshotPart as SnapshotPart,
type StepFinishPart as StepFinishPart,
type StepStartPart as StepStartPart,
type SymbolSource as SymbolSource,
type TextPart as TextPart,
type TextPartInput as TextPartInput,
type ToolPart as ToolPart,
type ToolStateCompleted as ToolStateCompleted,
type ToolStateError as ToolStateError,
type ToolStatePending as ToolStatePending,
type ToolStateRunning as ToolStateRunning,
type UserMessage as UserMessage,
type SessionListResponse as SessionListResponse,
type SessionDeleteResponse as SessionDeleteResponse,
type SessionAbortResponse as SessionAbortResponse,
type SessionInitResponse as SessionInitResponse,
type SessionMessagesResponse as SessionMessagesResponse,
type SessionSummarizeResponse as SessionSummarizeResponse,
type SessionChatParams as SessionChatParams,
type SessionInitParams as SessionInitParams,
type SessionRevertParams as SessionRevertParams,
type SessionSummarizeParams as SessionSummarizeParams,
};
export {
Tui as Tui,
type TuiAppendPromptResponse as TuiAppendPromptResponse,
type TuiOpenHelpResponse as TuiOpenHelpResponse,
type TuiAppendPromptParams as TuiAppendPromptParams,
};
export type MessageAbortedError = API.MessageAbortedError;
export type ProviderAuthError = API.ProviderAuthError;
export type UnknownError = API.UnknownError;
}

View File

@@ -0,0 +1,3 @@
# `core`
This directory holds public modules implementing non-resource-specific SDK functionality.

View File

@@ -0,0 +1,92 @@
// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
import { type Opencode } from '../client';
import { type PromiseOrValue } from '../internal/types';
import { APIResponseProps, defaultParseResponse } from '../internal/parse';
/**
* A subclass of `Promise` providing additional helper methods
* for interacting with the SDK.
*/
export class APIPromise<T> extends Promise<T> {
private parsedPromise: Promise<T> | undefined;
#client: Opencode;
constructor(
client: Opencode,
private responsePromise: Promise<APIResponseProps>,
private parseResponse: (
client: Opencode,
props: APIResponseProps,
) => PromiseOrValue<T> = defaultParseResponse,
) {
super((resolve) => {
// this is maybe a bit weird but this has to be a no-op to not implicitly
// parse the response body; instead .then, .catch, .finally are overridden
// to parse the response
resolve(null as any);
});
this.#client = client;
}
_thenUnwrap<U>(transform: (data: T, props: APIResponseProps) => U): APIPromise<U> {
return new APIPromise(this.#client, this.responsePromise, async (client, props) =>
transform(await this.parseResponse(client, props), props),
);
}
/**
* Gets the raw `Response` instance instead of parsing the response
* data.
*
* If you want to parse the response body but still get the `Response`
* instance, you can use {@link withResponse()}.
*
* 👋 Getting the wrong TypeScript type for `Response`?
* Try setting `"moduleResolution": "NodeNext"` or add `"lib": ["DOM"]`
* to your `tsconfig.json`.
*/
asResponse(): Promise<Response> {
return this.responsePromise.then((p) => p.response);
}
/**
* Gets the parsed response data and the raw `Response` instance.
*
* If you just want to get the raw `Response` instance without parsing it,
* you can use {@link asResponse()}.
*
* 👋 Getting the wrong TypeScript type for `Response`?
* Try setting `"moduleResolution": "NodeNext"` or add `"lib": ["DOM"]`
* to your `tsconfig.json`.
*/
async withResponse(): Promise<{ data: T; response: Response }> {
const [data, response] = await Promise.all([this.parse(), this.asResponse()]);
return { data, response };
}
private parse(): Promise<T> {
if (!this.parsedPromise) {
this.parsedPromise = this.responsePromise.then((data) => this.parseResponse(this.#client, data));
}
return this.parsedPromise;
}
override then<TResult1 = T, TResult2 = never>(
onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | undefined | null,
onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | undefined | null,
): Promise<TResult1 | TResult2> {
return this.parse().then(onfulfilled, onrejected);
}
override catch<TResult = never>(
onrejected?: ((reason: any) => TResult | PromiseLike<TResult>) | undefined | null,
): Promise<T | TResult> {
return this.parse().catch(onrejected);
}
override finally(onfinally?: (() => void) | undefined | null): Promise<T> {
return this.parse().finally(onfinally);
}
}

View File

@@ -0,0 +1,130 @@
// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
import { castToError } from '../internal/errors';
export class OpencodeError extends Error {}
export class APIError<
TStatus extends number | undefined = number | undefined,
THeaders extends Headers | undefined = Headers | undefined,
TError extends Object | undefined = Object | undefined,
> extends OpencodeError {
/** HTTP status for the response that caused the error */
readonly status: TStatus;
/** HTTP headers for the response that caused the error */
readonly headers: THeaders;
/** JSON body of the response that caused the error */
readonly error: TError;
constructor(status: TStatus, error: TError, message: string | undefined, headers: THeaders) {
super(`${APIError.makeMessage(status, error, message)}`);
this.status = status;
this.headers = headers;
this.error = error;
}
private static makeMessage(status: number | undefined, error: any, message: string | undefined) {
const msg =
error?.message ?
typeof error.message === 'string' ?
error.message
: JSON.stringify(error.message)
: error ? JSON.stringify(error)
: message;
if (status && msg) {
return `${status} ${msg}`;
}
if (status) {
return `${status} status code (no body)`;
}
if (msg) {
return msg;
}
return '(no status code or body)';
}
static generate(
status: number | undefined,
errorResponse: Object | undefined,
message: string | undefined,
headers: Headers | undefined,
): APIError {
if (!status || !headers) {
return new APIConnectionError({ message, cause: castToError(errorResponse) });
}
const error = errorResponse as Record<string, any>;
if (status === 400) {
return new BadRequestError(status, error, message, headers);
}
if (status === 401) {
return new AuthenticationError(status, error, message, headers);
}
if (status === 403) {
return new PermissionDeniedError(status, error, message, headers);
}
if (status === 404) {
return new NotFoundError(status, error, message, headers);
}
if (status === 409) {
return new ConflictError(status, error, message, headers);
}
if (status === 422) {
return new UnprocessableEntityError(status, error, message, headers);
}
if (status === 429) {
return new RateLimitError(status, error, message, headers);
}
if (status >= 500) {
return new InternalServerError(status, error, message, headers);
}
return new APIError(status, error, message, headers);
}
}
export class APIUserAbortError extends APIError<undefined, undefined, undefined> {
constructor({ message }: { message?: string } = {}) {
super(undefined, undefined, message || 'Request was aborted.', undefined);
}
}
export class APIConnectionError extends APIError<undefined, undefined, undefined> {
constructor({ message, cause }: { message?: string | undefined; cause?: Error | undefined }) {
super(undefined, undefined, message || 'Connection error.', undefined);
// in some environments the 'cause' property is already declared
// @ts-ignore
if (cause) this.cause = cause;
}
}
export class APIConnectionTimeoutError extends APIConnectionError {
constructor({ message }: { message?: string } = {}) {
super({ message: message ?? 'Request timed out.' });
}
}
export class BadRequestError extends APIError<400, Headers> {}
export class AuthenticationError extends APIError<401, Headers> {}
export class PermissionDeniedError extends APIError<403, Headers> {}
export class NotFoundError extends APIError<404, Headers> {}
export class ConflictError extends APIError<409, Headers> {}
export class UnprocessableEntityError extends APIError<422, Headers> {}
export class RateLimitError extends APIError<429, Headers> {}
export class InternalServerError extends APIError<number, Headers> {}

View File

@@ -0,0 +1,11 @@
// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
import type { Opencode } from '../client';
export abstract class APIResource {
protected _client: Opencode;
constructor(client: Opencode) {
this._client = client;
}
}

View File

@@ -0,0 +1,315 @@
import { OpencodeError } from './error';
import { type ReadableStream } from '../internal/shim-types';
import { makeReadableStream } from '../internal/shims';
import { findDoubleNewlineIndex, LineDecoder } from '../internal/decoders/line';
import { ReadableStreamToAsyncIterable } from '../internal/shims';
import { isAbortError } from '../internal/errors';
import { encodeUTF8 } from '../internal/utils/bytes';
import { loggerFor } from '../internal/utils/log';
import type { Opencode } from '../client';
type Bytes = string | ArrayBuffer | Uint8Array | null | undefined;
export type ServerSentEvent = {
event: string | null;
data: string;
raw: string[];
};
export class Stream<Item> implements AsyncIterable<Item> {
controller: AbortController;
#client: Opencode | undefined;
constructor(
private iterator: () => AsyncIterator<Item>,
controller: AbortController,
client?: Opencode,
) {
this.controller = controller;
this.#client = client;
}
static fromSSEResponse<Item>(
response: Response,
controller: AbortController,
client?: Opencode,
): Stream<Item> {
let consumed = false;
const logger = client ? loggerFor(client) : console;
async function* iterator(): AsyncIterator<Item, any, undefined> {
if (consumed) {
throw new OpencodeError('Cannot iterate over a consumed stream, use `.tee()` to split the stream.');
}
consumed = true;
let done = false;
try {
for await (const sse of _iterSSEMessages(response, controller)) {
try {
yield JSON.parse(sse.data);
} catch (e) {
logger.error(`Could not parse message into JSON:`, sse.data);
logger.error(`From chunk:`, sse.raw);
throw e;
}
}
done = true;
} catch (e) {
// If the user calls `stream.controller.abort()`, we should exit without throwing.
if (isAbortError(e)) return;
throw e;
} finally {
// If the user `break`s, abort the ongoing request.
if (!done) controller.abort();
}
}
return new Stream(iterator, controller, client);
}
/**
* Generates a Stream from a newline-separated ReadableStream
* where each item is a JSON value.
*/
static fromReadableStream<Item>(
readableStream: ReadableStream,
controller: AbortController,
client?: Opencode,
): Stream<Item> {
let consumed = false;
async function* iterLines(): AsyncGenerator<string, void, unknown> {
const lineDecoder = new LineDecoder();
const iter = ReadableStreamToAsyncIterable<Bytes>(readableStream);
for await (const chunk of iter) {
for (const line of lineDecoder.decode(chunk)) {
yield line;
}
}
for (const line of lineDecoder.flush()) {
yield line;
}
}
async function* iterator(): AsyncIterator<Item, any, undefined> {
if (consumed) {
throw new OpencodeError('Cannot iterate over a consumed stream, use `.tee()` to split the stream.');
}
consumed = true;
let done = false;
try {
for await (const line of iterLines()) {
if (done) continue;
if (line) yield JSON.parse(line);
}
done = true;
} catch (e) {
// If the user calls `stream.controller.abort()`, we should exit without throwing.
if (isAbortError(e)) return;
throw e;
} finally {
// If the user `break`s, abort the ongoing request.
if (!done) controller.abort();
}
}
return new Stream(iterator, controller, client);
}
[Symbol.asyncIterator](): AsyncIterator<Item> {
return this.iterator();
}
/**
* Splits the stream into two streams which can be
* independently read from at different speeds.
*/
tee(): [Stream<Item>, Stream<Item>] {
const left: Array<Promise<IteratorResult<Item>>> = [];
const right: Array<Promise<IteratorResult<Item>>> = [];
const iterator = this.iterator();
const teeIterator = (queue: Array<Promise<IteratorResult<Item>>>): AsyncIterator<Item> => {
return {
next: () => {
if (queue.length === 0) {
const result = iterator.next();
left.push(result);
right.push(result);
}
return queue.shift()!;
},
};
};
return [
new Stream(() => teeIterator(left), this.controller, this.#client),
new Stream(() => teeIterator(right), this.controller, this.#client),
];
}
/**
* Converts this stream to a newline-separated ReadableStream of
* JSON stringified values in the stream
* which can be turned back into a Stream with `Stream.fromReadableStream()`.
*/
toReadableStream(): ReadableStream {
const self = this;
let iter: AsyncIterator<Item>;
return makeReadableStream({
async start() {
iter = self[Symbol.asyncIterator]();
},
async pull(ctrl: any) {
try {
const { value, done } = await iter.next();
if (done) return ctrl.close();
const bytes = encodeUTF8(JSON.stringify(value) + '\n');
ctrl.enqueue(bytes);
} catch (err) {
ctrl.error(err);
}
},
async cancel() {
await iter.return?.();
},
});
}
}
export async function* _iterSSEMessages(
response: Response,
controller: AbortController,
): AsyncGenerator<ServerSentEvent, void, unknown> {
if (!response.body) {
controller.abort();
if (
typeof (globalThis as any).navigator !== 'undefined' &&
(globalThis as any).navigator.product === 'ReactNative'
) {
throw new OpencodeError(
`The default react-native fetch implementation does not support streaming. Please use expo/fetch: https://docs.expo.dev/versions/latest/sdk/expo/#expofetch-api`,
);
}
throw new OpencodeError(`Attempted to iterate over a response with no body`);
}
const sseDecoder = new SSEDecoder();
const lineDecoder = new LineDecoder();
const iter = ReadableStreamToAsyncIterable<Bytes>(response.body);
for await (const sseChunk of iterSSEChunks(iter)) {
for (const line of lineDecoder.decode(sseChunk)) {
const sse = sseDecoder.decode(line);
if (sse) yield sse;
}
}
for (const line of lineDecoder.flush()) {
const sse = sseDecoder.decode(line);
if (sse) yield sse;
}
}
/**
* Given an async iterable iterator, iterates over it and yields full
* SSE chunks, i.e. yields when a double new-line is encountered.
*/
async function* iterSSEChunks(iterator: AsyncIterableIterator<Bytes>): AsyncGenerator<Uint8Array> {
let data = new Uint8Array();
for await (const chunk of iterator) {
if (chunk == null) {
continue;
}
const binaryChunk =
chunk instanceof ArrayBuffer ? new Uint8Array(chunk)
: typeof chunk === 'string' ? encodeUTF8(chunk)
: chunk;
let newData = new Uint8Array(data.length + binaryChunk.length);
newData.set(data);
newData.set(binaryChunk, data.length);
data = newData;
let patternIndex;
while ((patternIndex = findDoubleNewlineIndex(data)) !== -1) {
yield data.slice(0, patternIndex);
data = data.slice(patternIndex);
}
}
if (data.length > 0) {
yield data;
}
}
class SSEDecoder {
private data: string[];
private event: string | null;
private chunks: string[];
constructor() {
this.event = null;
this.data = [];
this.chunks = [];
}
decode(line: string) {
if (line.endsWith('\r')) {
line = line.substring(0, line.length - 1);
}
if (!line) {
// empty line and we didn't previously encounter any messages
if (!this.event && !this.data.length) return null;
const sse: ServerSentEvent = {
event: this.event,
data: this.data.join('\n'),
raw: this.chunks,
};
this.event = null;
this.data = [];
this.chunks = [];
return sse;
}
this.chunks.push(line);
if (line.startsWith(':')) {
return null;
}
let [fieldname, _, value] = partition(line, ':');
if (value.startsWith(' ')) {
value = value.substring(1);
}
if (fieldname === 'event') {
this.event = value;
} else if (fieldname === 'data') {
this.data.push(value);
}
return null;
}
}
function partition(str: string, delimiter: string): [string, string, string] {
const index = str.indexOf(delimiter);
if (index !== -1) {
return [str.substring(0, index), delimiter, str.substring(index + delimiter.length)];
}
return [str, '', ''];
}

View File

@@ -0,0 +1,2 @@
export { type Uploadable } from '../internal/uploads';
export { toFile, type ToFileInput } from '../internal/to-file';

View File

@@ -0,0 +1,2 @@
/** @deprecated Import from ./core/error instead */
export * from './core/error';

22
packages/sdk/src/index.ts Normal file
View File

@@ -0,0 +1,22 @@
// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
export { Opencode as default } from './client';
export { type Uploadable, toFile } from './core/uploads';
export { APIPromise } from './core/api-promise';
export { Opencode, type ClientOptions } from './client';
export {
OpencodeError,
APIError,
APIConnectionError,
APIConnectionTimeoutError,
APIUserAbortError,
NotFoundError,
ConflictError,
RateLimitError,
BadRequestError,
AuthenticationError,
InternalServerError,
PermissionDeniedError,
UnprocessableEntityError,
} from './core/error';

View File

@@ -0,0 +1,3 @@
# `internal`
The modules in this directory are not importable outside this package and will change between releases.

View File

@@ -0,0 +1,93 @@
// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
export type Fetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
/**
* An alias to the builtin `RequestInit` type so we can
* easily alias it in import statements if there are name clashes.
*
* https://developer.mozilla.org/docs/Web/API/RequestInit
*/
type _RequestInit = RequestInit;
/**
* An alias to the builtin `Response` type so we can
* easily alias it in import statements if there are name clashes.
*
* https://developer.mozilla.org/docs/Web/API/Response
*/
type _Response = Response;
/**
* The type for the first argument to `fetch`.
*
* https://developer.mozilla.org/docs/Web/API/Window/fetch#resource
*/
type _RequestInfo = Request | URL | string;
/**
* The type for constructing `RequestInit` Headers.
*
* https://developer.mozilla.org/docs/Web/API/RequestInit#setting_headers
*/
type _HeadersInit = RequestInit['headers'];
/**
* The type for constructing `RequestInit` body.
*
* https://developer.mozilla.org/docs/Web/API/RequestInit#body
*/
type _BodyInit = RequestInit['body'];
/**
* An alias to the builtin `Array<T>` type so we can
* easily alias it in import statements if there are name clashes.
*/
type _Array<T> = Array<T>;
/**
* An alias to the builtin `Record<K, T>` type so we can
* easily alias it in import statements if there are name clashes.
*/
type _Record<K extends keyof any, T> = Record<K, T>;
export type {
_Array as Array,
_BodyInit as BodyInit,
_HeadersInit as HeadersInit,
_Record as Record,
_RequestInfo as RequestInfo,
_RequestInit as RequestInit,
_Response as Response,
};
/**
* A copy of the builtin `EndingType` type as it isn't fully supported in certain
* environments and attempting to reference the global version will error.
*
* https://github.com/microsoft/TypeScript/blob/49ad1a3917a0ea57f5ff248159256e12bb1cb705/src/lib/dom.generated.d.ts#L27941
*/
type EndingType = 'native' | 'transparent';
/**
* A copy of the builtin `BlobPropertyBag` type as it isn't fully supported in certain
* environments and attempting to reference the global version will error.
*
* https://github.com/microsoft/TypeScript/blob/49ad1a3917a0ea57f5ff248159256e12bb1cb705/src/lib/dom.generated.d.ts#L154
* https://developer.mozilla.org/en-US/docs/Web/API/Blob/Blob#options
*/
export interface BlobPropertyBag {
endings?: EndingType;
type?: string;
}
/**
* A copy of the builtin `FilePropertyBag` type as it isn't fully supported in certain
* environments and attempting to reference the global version will error.
*
* https://github.com/microsoft/TypeScript/blob/49ad1a3917a0ea57f5ff248159256e12bb1cb705/src/lib/dom.generated.d.ts#L503
* https://developer.mozilla.org/en-US/docs/Web/API/File/File#options
*/
export interface FilePropertyBag extends BlobPropertyBag {
lastModified?: number;
}

View File

@@ -0,0 +1,135 @@
import { concatBytes, decodeUTF8, encodeUTF8 } from '../utils/bytes';
export type Bytes = string | ArrayBuffer | Uint8Array | null | undefined;
/**
* A re-implementation of httpx's `LineDecoder` in Python that handles incrementally
* reading lines from text.
*
* https://github.com/encode/httpx/blob/920333ea98118e9cf617f246905d7b202510941c/httpx/_decoders.py#L258
*/
export class LineDecoder {
// prettier-ignore
static NEWLINE_CHARS = new Set(['\n', '\r']);
static NEWLINE_REGEXP = /\r\n|[\n\r]/g;
#buffer: Uint8Array;
#carriageReturnIndex: number | null;
constructor() {
this.#buffer = new Uint8Array();
this.#carriageReturnIndex = null;
}
decode(chunk: Bytes): string[] {
if (chunk == null) {
return [];
}
const binaryChunk =
chunk instanceof ArrayBuffer ? new Uint8Array(chunk)
: typeof chunk === 'string' ? encodeUTF8(chunk)
: chunk;
this.#buffer = concatBytes([this.#buffer, binaryChunk]);
const lines: string[] = [];
let patternIndex;
while ((patternIndex = findNewlineIndex(this.#buffer, this.#carriageReturnIndex)) != null) {
if (patternIndex.carriage && this.#carriageReturnIndex == null) {
// skip until we either get a corresponding `\n`, a new `\r` or nothing
this.#carriageReturnIndex = patternIndex.index;
continue;
}
// we got double \r or \rtext\n
if (
this.#carriageReturnIndex != null &&
(patternIndex.index !== this.#carriageReturnIndex + 1 || patternIndex.carriage)
) {
lines.push(decodeUTF8(this.#buffer.subarray(0, this.#carriageReturnIndex - 1)));
this.#buffer = this.#buffer.subarray(this.#carriageReturnIndex);
this.#carriageReturnIndex = null;
continue;
}
const endIndex =
this.#carriageReturnIndex !== null ? patternIndex.preceding - 1 : patternIndex.preceding;
const line = decodeUTF8(this.#buffer.subarray(0, endIndex));
lines.push(line);
this.#buffer = this.#buffer.subarray(patternIndex.index);
this.#carriageReturnIndex = null;
}
return lines;
}
flush(): string[] {
if (!this.#buffer.length) {
return [];
}
return this.decode('\n');
}
}
/**
* This function searches the buffer for the end patterns, (\r or \n)
* and returns an object with the index preceding the matched newline and the
* index after the newline char. `null` is returned if no new line is found.
*
* ```ts
* findNewLineIndex('abc\ndef') -> { preceding: 2, index: 3 }
* ```
*/
function findNewlineIndex(
buffer: Uint8Array,
startIndex: number | null,
): { preceding: number; index: number; carriage: boolean } | null {
const newline = 0x0a; // \n
const carriage = 0x0d; // \r
for (let i = startIndex ?? 0; i < buffer.length; i++) {
if (buffer[i] === newline) {
return { preceding: i, index: i + 1, carriage: false };
}
if (buffer[i] === carriage) {
return { preceding: i, index: i + 1, carriage: true };
}
}
return null;
}
export function findDoubleNewlineIndex(buffer: Uint8Array): number {
// This function searches the buffer for the end patterns (\r\r, \n\n, \r\n\r\n)
// and returns the index right after the first occurrence of any pattern,
// or -1 if none of the patterns are found.
const newline = 0x0a; // \n
const carriage = 0x0d; // \r
for (let i = 0; i < buffer.length - 1; i++) {
if (buffer[i] === newline && buffer[i + 1] === newline) {
// \n\n
return i + 2;
}
if (buffer[i] === carriage && buffer[i + 1] === carriage) {
// \r\r
return i + 2;
}
if (
buffer[i] === carriage &&
buffer[i + 1] === newline &&
i + 3 < buffer.length &&
buffer[i + 2] === carriage &&
buffer[i + 3] === newline
) {
// \r\n\r\n
return i + 4;
}
}
return -1;
}

View File

@@ -0,0 +1,196 @@
// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
import { VERSION } from '../version';
export const isRunningInBrowser = () => {
return (
// @ts-ignore
typeof window !== 'undefined' &&
// @ts-ignore
typeof window.document !== 'undefined' &&
// @ts-ignore
typeof navigator !== 'undefined'
);
};
type DetectedPlatform = 'deno' | 'node' | 'edge' | 'unknown';
/**
* Note this does not detect 'browser'; for that, use getBrowserInfo().
*/
function getDetectedPlatform(): DetectedPlatform {
if (typeof Deno !== 'undefined' && Deno.build != null) {
return 'deno';
}
if (typeof EdgeRuntime !== 'undefined') {
return 'edge';
}
if (
Object.prototype.toString.call(
typeof (globalThis as any).process !== 'undefined' ? (globalThis as any).process : 0,
) === '[object process]'
) {
return 'node';
}
return 'unknown';
}
declare const Deno: any;
declare const EdgeRuntime: any;
type Arch = 'x32' | 'x64' | 'arm' | 'arm64' | `other:${string}` | 'unknown';
type PlatformName =
| 'MacOS'
| 'Linux'
| 'Windows'
| 'FreeBSD'
| 'OpenBSD'
| 'iOS'
| 'Android'
| `Other:${string}`
| 'Unknown';
type Browser = 'ie' | 'edge' | 'chrome' | 'firefox' | 'safari';
type PlatformProperties = {
'X-Stainless-Lang': 'js';
'X-Stainless-Package-Version': string;
'X-Stainless-OS': PlatformName;
'X-Stainless-Arch': Arch;
'X-Stainless-Runtime': 'node' | 'deno' | 'edge' | `browser:${Browser}` | 'unknown';
'X-Stainless-Runtime-Version': string;
};
const getPlatformProperties = (): PlatformProperties => {
const detectedPlatform = getDetectedPlatform();
if (detectedPlatform === 'deno') {
return {
'X-Stainless-Lang': 'js',
'X-Stainless-Package-Version': VERSION,
'X-Stainless-OS': normalizePlatform(Deno.build.os),
'X-Stainless-Arch': normalizeArch(Deno.build.arch),
'X-Stainless-Runtime': 'deno',
'X-Stainless-Runtime-Version':
typeof Deno.version === 'string' ? Deno.version : Deno.version?.deno ?? 'unknown',
};
}
if (typeof EdgeRuntime !== 'undefined') {
return {
'X-Stainless-Lang': 'js',
'X-Stainless-Package-Version': VERSION,
'X-Stainless-OS': 'Unknown',
'X-Stainless-Arch': `other:${EdgeRuntime}`,
'X-Stainless-Runtime': 'edge',
'X-Stainless-Runtime-Version': (globalThis as any).process.version,
};
}
// Check if Node.js
if (detectedPlatform === 'node') {
return {
'X-Stainless-Lang': 'js',
'X-Stainless-Package-Version': VERSION,
'X-Stainless-OS': normalizePlatform((globalThis as any).process.platform ?? 'unknown'),
'X-Stainless-Arch': normalizeArch((globalThis as any).process.arch ?? 'unknown'),
'X-Stainless-Runtime': 'node',
'X-Stainless-Runtime-Version': (globalThis as any).process.version ?? 'unknown',
};
}
const browserInfo = getBrowserInfo();
if (browserInfo) {
return {
'X-Stainless-Lang': 'js',
'X-Stainless-Package-Version': VERSION,
'X-Stainless-OS': 'Unknown',
'X-Stainless-Arch': 'unknown',
'X-Stainless-Runtime': `browser:${browserInfo.browser}`,
'X-Stainless-Runtime-Version': browserInfo.version,
};
}
// TODO add support for Cloudflare workers, etc.
return {
'X-Stainless-Lang': 'js',
'X-Stainless-Package-Version': VERSION,
'X-Stainless-OS': 'Unknown',
'X-Stainless-Arch': 'unknown',
'X-Stainless-Runtime': 'unknown',
'X-Stainless-Runtime-Version': 'unknown',
};
};
type BrowserInfo = {
browser: Browser;
version: string;
};
declare const navigator: { userAgent: string } | undefined;
// Note: modified from https://github.com/JS-DevTools/host-environment/blob/b1ab79ecde37db5d6e163c050e54fe7d287d7c92/src/isomorphic.browser.ts
function getBrowserInfo(): BrowserInfo | null {
if (typeof navigator === 'undefined' || !navigator) {
return null;
}
// NOTE: The order matters here!
const browserPatterns = [
{ key: 'edge' as const, pattern: /Edge(?:\W+(\d+)\.(\d+)(?:\.(\d+))?)?/ },
{ key: 'ie' as const, pattern: /MSIE(?:\W+(\d+)\.(\d+)(?:\.(\d+))?)?/ },
{ key: 'ie' as const, pattern: /Trident(?:.*rv\:(\d+)\.(\d+)(?:\.(\d+))?)?/ },
{ key: 'chrome' as const, pattern: /Chrome(?:\W+(\d+)\.(\d+)(?:\.(\d+))?)?/ },
{ key: 'firefox' as const, pattern: /Firefox(?:\W+(\d+)\.(\d+)(?:\.(\d+))?)?/ },
{ key: 'safari' as const, pattern: /(?:Version\W+(\d+)\.(\d+)(?:\.(\d+))?)?(?:\W+Mobile\S*)?\W+Safari/ },
];
// Find the FIRST matching browser
for (const { key, pattern } of browserPatterns) {
const match = pattern.exec(navigator.userAgent);
if (match) {
const major = match[1] || 0;
const minor = match[2] || 0;
const patch = match[3] || 0;
return { browser: key, version: `${major}.${minor}.${patch}` };
}
}
return null;
}
const normalizeArch = (arch: string): Arch => {
// Node docs:
// - https://nodejs.org/api/process.html#processarch
// Deno docs:
// - https://doc.deno.land/deno/stable/~/Deno.build
if (arch === 'x32') return 'x32';
if (arch === 'x86_64' || arch === 'x64') return 'x64';
if (arch === 'arm') return 'arm';
if (arch === 'aarch64' || arch === 'arm64') return 'arm64';
if (arch) return `other:${arch}`;
return 'unknown';
};
const normalizePlatform = (platform: string): PlatformName => {
// Node platforms:
// - https://nodejs.org/api/process.html#processplatform
// Deno platforms:
// - https://doc.deno.land/deno/stable/~/Deno.build
// - https://github.com/denoland/deno/issues/14799
platform = platform.toLowerCase();
// NOTE: this iOS check is untested and may not work
// Node does not work natively on IOS, there is a fork at
// https://github.com/nodejs-mobile/nodejs-mobile
// however it is unknown at the time of writing how to detect if it is running
if (platform.includes('ios')) return 'iOS';
if (platform === 'android') return 'Android';
if (platform === 'darwin') return 'MacOS';
if (platform === 'win32') return 'Windows';
if (platform === 'freebsd') return 'FreeBSD';
if (platform === 'openbsd') return 'OpenBSD';
if (platform === 'linux') return 'Linux';
if (platform) return `Other:${platform}`;
return 'Unknown';
};
let _platformHeaders: PlatformProperties;
export const getPlatformHeaders = () => {
return (_platformHeaders ??= getPlatformProperties());
};

View File

@@ -0,0 +1,33 @@
// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
export function isAbortError(err: unknown) {
return (
typeof err === 'object' &&
err !== null &&
// Spec-compliant fetch implementations
(('name' in err && (err as any).name === 'AbortError') ||
// Expo fetch
('message' in err && String((err as any).message).includes('FetchRequestCanceledException')))
);
}
export const castToError = (err: any): Error => {
if (err instanceof Error) return err;
if (typeof err === 'object' && err !== null) {
try {
if (Object.prototype.toString.call(err) === '[object Error]') {
// @ts-ignore - not all envs have native support for cause yet
const error = new Error(err.message, err.cause ? { cause: err.cause } : {});
if (err.stack) error.stack = err.stack;
// @ts-ignore - not all envs have native support for cause yet
if (err.cause && !error.cause) error.cause = err.cause;
if (err.name) error.name = err.name;
return error;
}
} catch {}
try {
return new Error(JSON.stringify(err));
} catch {}
}
return new Error(err);
};

View File

@@ -0,0 +1,97 @@
// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
import { isReadonlyArray } from './utils/values';
type HeaderValue = string | undefined | null;
export type HeadersLike =
| Headers
| readonly HeaderValue[][]
| Record<string, HeaderValue | readonly HeaderValue[]>
| undefined
| null
| NullableHeaders;
const brand_privateNullableHeaders = /* @__PURE__ */ Symbol('brand.privateNullableHeaders');
/**
* @internal
* Users can pass explicit nulls to unset default headers. When we parse them
* into a standard headers type we need to preserve that information.
*/
export type NullableHeaders = {
/** Brand check, prevent users from creating a NullableHeaders. */
[brand_privateNullableHeaders]: true;
/** Parsed headers. */
values: Headers;
/** Set of lowercase header names explicitly set to null. */
nulls: Set<string>;
};
function* iterateHeaders(headers: HeadersLike): IterableIterator<readonly [string, string | null]> {
if (!headers) return;
if (brand_privateNullableHeaders in headers) {
const { values, nulls } = headers;
yield* values.entries();
for (const name of nulls) {
yield [name, null];
}
return;
}
let shouldClear = false;
let iter: Iterable<readonly (HeaderValue | readonly HeaderValue[])[]>;
if (headers instanceof Headers) {
iter = headers.entries();
} else if (isReadonlyArray(headers)) {
iter = headers;
} else {
shouldClear = true;
iter = Object.entries(headers ?? {});
}
for (let row of iter) {
const name = row[0];
if (typeof name !== 'string') throw new TypeError('expected header name to be a string');
const values = isReadonlyArray(row[1]) ? row[1] : [row[1]];
let didClear = false;
for (const value of values) {
if (value === undefined) continue;
// Objects keys always overwrite older headers, they never append.
// Yield a null to clear the header before adding the new values.
if (shouldClear && !didClear) {
didClear = true;
yield [name, null];
}
yield [name, value];
}
}
}
export const buildHeaders = (newHeaders: HeadersLike[]): NullableHeaders => {
const targetHeaders = new Headers();
const nullHeaders = new Set<string>();
for (const headers of newHeaders) {
const seenHeaders = new Set<string>();
for (const [name, value] of iterateHeaders(headers)) {
const lowerName = name.toLowerCase();
if (!seenHeaders.has(lowerName)) {
targetHeaders.delete(name);
seenHeaders.add(lowerName);
}
if (value === null) {
targetHeaders.delete(name);
nullHeaders.add(lowerName);
} else {
targetHeaders.append(name, value);
nullHeaders.delete(lowerName);
}
}
}
return { [brand_privateNullableHeaders]: true, values: targetHeaders, nulls: nullHeaders };
};
export const isEmptyHeaders = (headers: HeadersLike) => {
for (const _ of iterateHeaders(headers)) return false;
return true;
};

View File

@@ -0,0 +1,64 @@
// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
import type { FinalRequestOptions } from './request-options';
import { Stream } from '../core/streaming';
import { type Opencode } from '../client';
import { formatRequestDetails, loggerFor } from './utils/log';
export type APIResponseProps = {
response: Response;
options: FinalRequestOptions;
controller: AbortController;
requestLogID: string;
retryOfRequestLogID: string | undefined;
startTime: number;
};
export async function defaultParseResponse<T>(client: Opencode, props: APIResponseProps): Promise<T> {
const { response, requestLogID, retryOfRequestLogID, startTime } = props;
const body = await (async () => {
if (props.options.stream) {
loggerFor(client).debug('response', response.status, response.url, response.headers, response.body);
// Note: there is an invariant here that isn't represented in the type system
// that if you set `stream: true` the response type must also be `Stream<T>`
if (props.options.__streamClass) {
return props.options.__streamClass.fromSSEResponse(response, props.controller, client) as any;
}
return Stream.fromSSEResponse(response, props.controller, client) as any;
}
// fetch refuses to read the body when the status code is 204.
if (response.status === 204) {
return null as T;
}
if (props.options.__binaryResponse) {
return response as unknown as T;
}
const contentType = response.headers.get('content-type');
const mediaType = contentType?.split(';')[0]?.trim();
const isJSON = mediaType?.includes('application/json') || mediaType?.endsWith('+json');
if (isJSON) {
const json = await response.json();
return json as T;
}
const text = await response.text();
return text as unknown as T;
})();
loggerFor(client).debug(
`[${requestLogID}] response parsed`,
formatRequestDetails({
retryOfRequestLogID,
url: response.url,
status: response.status,
body,
durationMs: Date.now() - startTime,
}),
);
return body;
}

View File

@@ -0,0 +1,93 @@
// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
import { NullableHeaders } from './headers';
import type { BodyInit } from './builtin-types';
import { Stream } from '../core/streaming';
import type { HTTPMethod, MergedRequestInit } from './types';
import { type HeadersLike } from './headers';
export type FinalRequestOptions = RequestOptions & { method: HTTPMethod; path: string };
export type RequestOptions = {
/**
* The HTTP method for the request (e.g., 'get', 'post', 'put', 'delete').
*/
method?: HTTPMethod;
/**
* The URL path for the request.
*
* @example "/v1/foo"
*/
path?: string;
/**
* Query parameters to include in the request URL.
*/
query?: object | undefined | null;
/**
* The request body. Can be a string, JSON object, FormData, or other supported types.
*/
body?: unknown;
/**
* HTTP headers to include with the request. Can be a Headers object, plain object, or array of tuples.
*/
headers?: HeadersLike;
/**
* The maximum number of times that the client will retry a request in case of a
* temporary failure, like a network error or a 5XX error from the server.
*
* @default 2
*/
maxRetries?: number;
stream?: boolean | undefined;
/**
* The maximum amount of time (in milliseconds) that the client should wait for a response
* from the server before timing out a single request.
*
* @unit milliseconds
*/
timeout?: number;
/**
* Additional `RequestInit` options to be passed to the underlying `fetch` call.
* These options will be merged with the client's default fetch options.
*/
fetchOptions?: MergedRequestInit;
/**
* An AbortSignal that can be used to cancel the request.
*/
signal?: AbortSignal | undefined | null;
/**
* A unique key for this request to enable idempotency.
*/
idempotencyKey?: string;
/**
* Override the default base URL for this specific request.
*/
defaultBaseURL?: string | undefined;
__binaryResponse?: boolean | undefined;
__streamClass?: typeof Stream;
};
export type EncodedContent = { bodyHeaders: HeadersLike; body: BodyInit };
export type RequestEncoder = (request: { headers: NullableHeaders; body: unknown }) => EncodedContent;
export const FallbackEncoder: RequestEncoder = ({ headers, body }) => {
return {
bodyHeaders: {
'content-type': 'application/json',
},
body: JSON.stringify(body),
};
};

View File

@@ -0,0 +1,26 @@
// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
/**
* Shims for types that we can't always rely on being available globally.
*
* Note: these only exist at the type-level, there is no corresponding runtime
* version for any of these symbols.
*/
type NeverToAny<T> = T extends never ? any : T;
/** @ts-ignore */
type _DOMReadableStream<R = any> = globalThis.ReadableStream<R>;
/** @ts-ignore */
type _NodeReadableStream<R = any> = import('stream/web').ReadableStream<R>;
type _ConditionalNodeReadableStream<R = any> =
typeof globalThis extends { ReadableStream: any } ? never : _NodeReadableStream<R>;
type _ReadableStream<R = any> = NeverToAny<
| ([0] extends [1 & _DOMReadableStream<R>] ? never : _DOMReadableStream<R>)
| ([0] extends [1 & _ConditionalNodeReadableStream<R>] ? never : _ConditionalNodeReadableStream<R>)
>;
export type { _ReadableStream as ReadableStream };

View File

@@ -0,0 +1,107 @@
// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
/**
* This module provides internal shims and utility functions for environments where certain Node.js or global types may not be available.
*
* These are used to ensure we can provide a consistent behaviour between different JavaScript environments and good error
* messages in cases where an environment isn't fully supported.
*/
import type { Fetch } from './builtin-types';
import type { ReadableStream } from './shim-types';
export function getDefaultFetch(): Fetch {
if (typeof fetch !== 'undefined') {
return fetch as any;
}
throw new Error(
'`fetch` is not defined as a global; Either pass `fetch` to the client, `new Opencode({ fetch })` or polyfill the global, `globalThis.fetch = fetch`',
);
}
type ReadableStreamArgs = ConstructorParameters<typeof ReadableStream>;
export function makeReadableStream(...args: ReadableStreamArgs): ReadableStream {
const ReadableStream = (globalThis as any).ReadableStream;
if (typeof ReadableStream === 'undefined') {
// Note: All of the platforms / runtimes we officially support already define
// `ReadableStream` as a global, so this should only ever be hit on unsupported runtimes.
throw new Error(
'`ReadableStream` is not defined as a global; You will need to polyfill it, `globalThis.ReadableStream = ReadableStream`',
);
}
return new ReadableStream(...args);
}
export function ReadableStreamFrom<T>(iterable: Iterable<T> | AsyncIterable<T>): ReadableStream<T> {
let iter: AsyncIterator<T> | Iterator<T> =
Symbol.asyncIterator in iterable ? iterable[Symbol.asyncIterator]() : iterable[Symbol.iterator]();
return makeReadableStream({
start() {},
async pull(controller: any) {
const { done, value } = await iter.next();
if (done) {
controller.close();
} else {
controller.enqueue(value);
}
},
async cancel() {
await iter.return?.();
},
});
}
/**
* Most browsers don't yet have async iterable support for ReadableStream,
* and Node has a very different way of reading bytes from its "ReadableStream".
*
* This polyfill was pulled from https://github.com/MattiasBuelens/web-streams-polyfill/pull/122#issuecomment-1627354490
*/
export function ReadableStreamToAsyncIterable<T>(stream: any): AsyncIterableIterator<T> {
if (stream[Symbol.asyncIterator]) return stream;
const reader = stream.getReader();
return {
async next() {
try {
const result = await reader.read();
if (result?.done) reader.releaseLock(); // release lock when stream becomes closed
return result;
} catch (e) {
reader.releaseLock(); // release lock when stream becomes errored
throw e;
}
},
async return() {
const cancelPromise = reader.cancel();
reader.releaseLock();
await cancelPromise;
return { done: true, value: undefined };
},
[Symbol.asyncIterator]() {
return this;
},
};
}
/**
* Cancels a ReadableStream we don't need to consume.
* See https://undici.nodejs.org/#/?id=garbage-collection
*/
export async function CancelReadableStream(stream: any): Promise<void> {
if (stream === null || typeof stream !== 'object') return;
if (stream[Symbol.asyncIterator]) {
await stream[Symbol.asyncIterator]().return?.();
return;
}
const reader = stream.getReader();
const cancelPromise = reader.cancel();
reader.releaseLock();
await cancelPromise;
}

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