Compare commits

..
49 Commits
Author SHA1 Message Date
Dax Raad 24515162fa ci: ignore 2025-08-03 10:52:35 -04:00
Dax Raad 53aa899e45 ci: ignore 2025-08-03 10:42:52 -04:00
Dax Raad 7e763e1c06 fix shell error 128 2025-08-03 10:30:23 -04:00
GitHub Action b0f2cc0c22 ignore: update download stats 2025-08-03 2025-08-03 12:04:04 +00:00
Aiden ClineandGitHub f90aa62784 fix: expand tilde for file: references (#1553) 2025-08-03 06:15:06 -05:00
Dax Raad 852191f6cb ci: ignore 2025-08-03 03:54:17 -04:00
Dax Raad c5e9dc081c ci: bun cache 2025-08-03 03:53:31 -04:00
Dax Raad 49c8889228 ci: ignore 2025-08-03 03:45:05 -04:00
Dax Raad f739e1a958 ci: ignore 2025-08-03 03:37:53 -04:00
Dax Raad 841f1907bb ci: ignore 2025-08-03 03:35:17 -04:00
The PangolierandGitHub 9255c507d6 Share link hotfix (#1513) 2025-08-03 03:02:24 -04:00
Yordis PrietoandGitHub 2711047166 remove: delete extension test file (#1554) 2025-08-03 02:58:10 -04:00
Frank 908048baef sync 2025-08-02 21:28:03 -04:00
Frank a9fbe07408 Add Zhipu AI provider 2025-08-02 21:20:44 -04:00
Dax Raad 0ae213ee0e ci: ignore 2025-08-02 18:56:34 -04:00
Dax Raad ca031278ca wip: plugins 2025-08-02 18:50:19 -04:00
Aiden ClineandGitHub ae6e47bb42 tweak: make gh action ignore url mentions of opencode (#1531) 2025-08-02 09:31:23 -05:00
Dominik EngelhardtandGitHub 42a5fcead4 Choose model according to the docs (#1536) 2025-08-02 09:29:03 -05:00
Yihui KhuuandGitHub 8ad83f71a9 fix(tui): attachment highlighting issues in messages (#1534) 2025-08-02 09:26:44 -05:00
Yihui KhuuandGitHub fa95c09cdc fix(tui): attachment source is not stored when using message from message history (#1542) 2025-08-02 09:23:32 -05:00
Aiden ClineandGitHub 0b132c032a ignore: fix dev branch (#1529) 2025-08-02 09:11:38 -05:00
GitHub Action 44d7103a42 ignore: update download stats 2025-08-02 2025-08-02 12:04:12 +00:00
Ricardo GonzalezandGitHub 8f45a0e227 feat(models): enable Kimi k2 ⇄ Claude trajectory handoff (#1525) 2025-08-01 23:05:06 -04:00
Aiden ClineandGitHub 6581741318 fix: include stderr in bash tool output (#1511) 2025-08-01 19:20:32 -05:00
Aiden ClineandGitHub 80d68d01f4 better configuration error messages (#1517) 2025-08-01 19:10:32 -04:00
Jay V fa9db3c167 docs: cerebras 2025-08-01 18:30:29 -04:00
opencode 5a727c0794 release: v0.3.112 2025-08-01 21:53:33 +00:00
Dax Raad 71cd84dbbb force models.dev refresh on auth login 2025-08-01 17:48:01 -04:00
Dax Raad e1b7e25f4d make top_p configurable 2025-08-01 17:03:33 -04:00
Dax Raad 98b6bb218b configurable lsp 2025-08-01 14:52:10 -04:00
Brinsil EliasandGitHub 5592ce8eaf fix(docs): Fix formatting for Node.js installation section (#1497) 2025-08-01 14:15:38 -04:00
CodinCatandGitHub 510fe8a72a handle the optional v in upgrade command when using curl (#1500) 2025-08-01 14:15:22 -04:00
Yordis PrietoandGitHub 04a1ab3893 chore: enhance bash command tests with config mock and timeout adjustments (#1486)
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
2025-08-01 14:14:54 -04:00
Dax Raad e74b4d098b allow search in provider select 2025-08-01 14:03:22 -04:00
Dax Raad 50e4b3e6a7 add version to user-agent 2025-08-01 12:18:09 -04:00
adamdotdevin 6ebd828aa5 fix: unshare command missing 2025-08-01 09:30:42 -05:00
Aiden ClineandGitHub 022c979d28 tweak: sanitize mcp server names (#831) 2025-08-01 09:11:40 -05:00
Aiden ClineandGitHub 4172e3ad28 fix: bash tool errors for chmod (#1502) 2025-08-01 09:10:09 -05:00
Aiden ClineandGitHub 90d1698aed fix: {file:...} references weren't being parsed correctly in some cases (#1499) 2025-08-01 08:39:21 -05:00
adamdotdevin b0c38ce56b ignore: include usage in local setup 2025-08-01 07:42:36 -05:00
GitHub Action 9b37d0e191 ignore: update download stats 2025-08-01 2025-08-01 12:04:33 +00:00
adamdotdevin ea794a4bf6 chore: add local qwen3 to config 2025-08-01 06:27:08 -05:00
Timo ClasenandGitHub 52f9b37576 docs(permissions): add wildcard example (#1494) 2025-08-01 05:24:32 -05:00
Dax Raad a0d2e53bde poll for models.dev changes 2025-07-31 23:47:42 -04:00
Dax Raad 851e900982 add user agent for models.dev request 2025-07-31 22:00:45 -04:00
Dax Raad 3aa6eeb426 do not mark errored tool calls as aborted 2025-07-31 21:45:40 -04:00
Dax Raad b6ee8e92f9 better guarding against bash commands that go outside of cwd 2025-07-31 21:42:30 -04:00
FrankandGitHub 44211e1526 Update STATS.md 2025-07-31 21:42:05 -04:00
Dax Raad 12f84f198f improve wildcard matching for permissions 2025-07-31 20:40:05 -04:00
72 changed files with 3754 additions and 2839 deletions
+5 -3
View File
@@ -7,8 +7,10 @@ on:
jobs:
opencode:
if: |
contains(github.event.comment.body, '/oc') ||
contains(github.event.comment.body, '/opencode')
contains(github.event.comment.body, ' /oc') ||
startsWith(github.event.comment.body, '/oc') ||
contains(github.event.comment.body, ' /opencode') ||
startsWith(github.event.comment.body, '/opencode')
runs-on: ubuntu-latest
permissions:
contents: read
@@ -24,4 +26,4 @@ jobs:
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
with:
model: anthropic/claude-sonnet-4-20250514
model: anthropic/claude-sonnet-4-20250514
+12 -1
View File
@@ -39,6 +39,15 @@ jobs:
with:
bun-version: 1.2.19
- name: Cache ~/.bun
id: cache-bun
uses: actions/cache@v3
with:
path: ~/.bun
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install makepkg
run: |
sudo apt-get update
@@ -53,9 +62,11 @@ jobs:
git config --global user.email "opencode@sst.dev"
git config --global user.name "opencode"
- name: Install dependencies
run: bun install
- name: Publish
run: |
bun install
OPENCODE_VERSION=${{ inputs.version }} ./script/publish.ts
env:
GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }}
+37 -33
View File
@@ -1,35 +1,39 @@
# Download Stats
| Date | GitHub Downloads | npm Downloads | Total |
| ---------- | ---------------- | ---------------- | ----------------- |
| 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) |
| 2025-06-30 | 20,127 (+1,338) | 41,059 (+1,639) | 61,186 (+2,977) |
| 2025-07-01 | 22,108 (+1,981) | 43,745 (+2,686) | 65,853 (+4,667) |
| 2025-07-02 | 24,814 (+2,706) | 46,168 (+2,423) | 70,982 (+5,129) |
| 2025-07-03 | 27,834 (+3,020) | 49,955 (+3,787) | 77,789 (+6,807) |
| 2025-07-04 | 30,608 (+2,774) | 54,758 (+4,803) | 85,366 (+7,577) |
| 2025-07-05 | 32,524 (+1,916) | 58,371 (+3,613) | 90,895 (+5,529) |
| 2025-07-06 | 33,766 (+1,242) | 59,694 (+1,323) | 93,460 (+2,565) |
| 2025-07-08 | 38,052 (+4,286) | 64,468 (+4,774) | 102,520 (+9,060) |
| 2025-07-10 | 43,796 (+5,744) | 71,402 (+6,934) | 115,198 (+12,678) |
| 2025-07-11 | 46,982 (+3,186) | 77,462 (+6,060) | 124,444 (+9,246) |
| 2025-07-12 | 49,302 (+2,320) | 82,177 (+4,715) | 131,479 (+7,035) |
| 2025-07-13 | 50,803 (+1,501) | 86,394 (+4,217) | 137,197 (+5,718) |
| 2025-07-14 | 53,283 (+2,480) | 87,860 (+1,466) | 141,143 (+3,946) |
| 2025-07-15 | 57,590 (+4,307) | 91,036 (+3,176) | 148,626 (+7,483) |
| 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-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) |
| 2025-07-26 | 100,646 (+4,229) | 131,411 (+4,426) | 232,057 (+8,655) |
| 2025-07-27 | 102,644 (+1,998) | 134,736 (+3,325) | 237,380 (+5,323) |
| 2025-07-28 | 105,446 (+2,802) | 136,016 (+1,280) | 241,462 (+4,082) |
| 2025-07-29 | 108,998 (+3,552) | 137,542 (+1,526) | 246,540 (+5,078) |
| 2025-07-30 | 113,544 (+4,546) | 140,317 (+2,775) | 253,861 (+7,321) |
| 2025-07-31 | 118,339 (+4,795) | 143,344 (+3,027) | 261,683 (+7,822) |
| Date | GitHub Downloads | npm Downloads | Total |
| ---------- | ---------------- | ---------------- | ---------------- |
| 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) |
| 2025-06-30 | 20,127 (+1,338) | 41,059 (+1,639) | 61,186 (+2,977) |
| 2025-07-01 | 22,108 (+1,981) | 43,745 (+2,686) | 65,853 (+4,667) |
| 2025-07-02 | 24,814 (+2,706) | 46,168 (+2,423) | 70,982 (+5,129) |
| 2025-07-03 | 27,834 (+3,020) | 49,955 (+3,787) | 77,789 (+6,807) |
| 2025-07-04 | 30,608 (+2,774) | 54,758 (+4,803) | 85,366 (+7,577) |
| 2025-07-05 | 32,524 (+1,916) | 58,371 (+3,613) | 90,895 (+5,529) |
| 2025-07-06 | 33,766 (+1,242) | 59,694 (+1,323) | 93,460 (+2,565) |
| 2025-07-08 | 38,052 (+4,286) | 64,468 (+4,774) | 102,520 (+9,060) |
| 2025-07-09 | 40,924 (+2,872) | 67,935 (+3,467) | 108,859 (+6,339) |
| 2025-07-10 | 43,796 (+2,872) | 71,402 (+3,467) | 115,198 (+6,339) |
| 2025-07-11 | 46,982 (+3,186) | 77,462 (+6,060) | 124,444 (+9,246) |
| 2025-07-12 | 49,302 (+2,320) | 82,177 (+4,715) | 131,479 (+7,035) |
| 2025-07-13 | 50,803 (+1,501) | 86,394 (+4,217) | 137,197 (+5,718) |
| 2025-07-14 | 53,283 (+2,480) | 87,860 (+1,466) | 141,143 (+3,946) |
| 2025-07-15 | 57,590 (+4,307) | 91,036 (+3,176) | 148,626 (+7,483) |
| 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-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) |
| 2025-07-26 | 100,646 (+4,229) | 131,411 (+4,426) | 232,057 (+8,655) |
| 2025-07-27 | 102,644 (+1,998) | 134,736 (+3,325) | 237,380 (+5,323) |
| 2025-07-28 | 105,446 (+2,802) | 136,016 (+1,280) | 241,462 (+4,082) |
| 2025-07-29 | 108,998 (+3,552) | 137,542 (+1,526) | 246,540 (+5,078) |
| 2025-07-30 | 113,544 (+4,546) | 140,317 (+2,775) | 253,861 (+7,321) |
| 2025-07-31 | 118,339 (+4,795) | 143,344 (+3,027) | 261,683 (+7,822) |
| 2025-08-01 | 123,539 (+5,200) | 146,680 (+3,336) | 270,219 (+8,536) |
| 2025-08-02 | 127,864 (+4,325) | 149,236 (+2,556) | 277,100 (+6,881) |
| 2025-08-03 | 131,397 (+3,533) | 150,451 (+1,215) | 281,848 (+4,748) |
+21 -7
View File
@@ -10,7 +10,7 @@
},
"packages/function": {
"name": "@opencode/function",
"version": "0.0.1",
"version": "0.3.113",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "22.0.0",
@@ -25,19 +25,21 @@
},
"packages/opencode": {
"name": "opencode",
"version": "0.0.0",
"version": "0.3.113",
"bin": {
"opencode": "./bin/opencode",
},
"dependencies": {
"@actions/core": "1.11.1",
"@actions/github": "6.0.1",
"@clack/prompts": "0.11.0",
"@clack/prompts": "1.0.0-alpha.1",
"@hono/zod-validator": "0.4.2",
"@modelcontextprotocol/sdk": "1.15.1",
"@octokit/graphql": "9.0.1",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.4.3",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@standard-schema/spec": "1.0.0",
"@zip.js/zip.js": "2.7.62",
"ai": "catalog:",
@@ -74,9 +76,19 @@
"zod-to-json-schema": "3.24.5",
},
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "0.3.113",
"devDependencies": {
"@hey-api/openapi-ts": "0.80.1",
"@opencode-ai/sdk": "workspace:*",
"@tsconfig/node22": "catalog:",
"typescript": "catalog:",
},
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "0.0.0",
"version": "0.3.113",
"devDependencies": {
"@hey-api/openapi-ts": "0.80.1",
"@tsconfig/node22": "catalog:",
@@ -85,7 +97,7 @@
},
"packages/web": {
"name": "@opencode/web",
"version": "0.0.1",
"version": "0.3.113",
"dependencies": {
"@astrojs/cloudflare": "^12.5.4",
"@astrojs/markdown-remark": "6.3.1",
@@ -222,9 +234,9 @@
"@capsizecss/unpack": ["@capsizecss/unpack@2.4.0", "", { "dependencies": { "blob-to-buffer": "^1.2.8", "cross-fetch": "^3.0.4", "fontkit": "^2.0.2" } }, "sha512-GrSU71meACqcmIUxPYOJvGKF0yryjN/L1aCuE9DViCTJI7bfkjgYDPD1zbNDcINJwSSP6UaBZY9GAbYDO7re0Q=="],
"@clack/core": ["@clack/core@0.5.0", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow=="],
"@clack/core": ["@clack/core@1.0.0-alpha.1", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-rFbCU83JnN7l3W1nfgCqqme4ZZvTTgsiKQ6FM0l+r0P+o2eJpExcocBUWUIwnDzL76Aca9VhUdWmB2MbUv+Qyg=="],
"@clack/prompts": ["@clack/prompts@0.11.0", "", { "dependencies": { "@clack/core": "0.5.0", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw=="],
"@clack/prompts": ["@clack/prompts@1.0.0-alpha.1", "", { "dependencies": { "@clack/core": "1.0.0-alpha.1", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-07MNT0OsxjKOcyVfX8KhXBhJiyUbDP1vuIAcHc+nx5v93MJO23pX3X/k3bWz6T3rpM9dgWPq90i4Jq7gZAyMbw=="],
"@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.0", "", { "dependencies": { "mime": "^3.0.0" } }, "sha512-+tv3z+SPp+gqTIcImN9o0hqE9xyfQjI1XD9pL6NuKjua9B1y7mNYv0S9cP+QEbA4ppVgGZEmKOvHX5G5Ei1CVA=="],
@@ -416,6 +428,8 @@
"@openauthjs/openauth": ["@openauthjs/openauth@0.4.3", "", { "dependencies": { "@standard-schema/spec": "1.0.0-beta.3", "aws4fetch": "1.0.20", "jose": "5.9.6" }, "peerDependencies": { "arctic": "^2.2.2", "hono": "^4.0.0" } }, "sha512-RlnjqvHzqcbFVymEwhlUEuac4utA5h4nhSK/i2szZuQmxTIqbGUxZ+nM+avM+VV4Ing+/ZaNLKILoXS3yrkOOw=="],
"@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
"@opencode-ai/sdk": ["@opencode-ai/sdk@workspace:packages/sdk/js"],
"@opencode/function": ["@opencode/function@workspace:packages/function"],
+1 -19
View File
@@ -1,24 +1,6 @@
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"openrouter": {
"models": {
"moonshotai/kimi-k2": {
"options": {
"provider": {
"order": ["baseten"],
"allow_fallbacks": false
}
}
}
}
},
"huggingface": {
"models": {
"Qwen/Qwen3-235B-A22B-Instruct-2507:fireworks-ai": {}
}
}
},
"plugin": ["./packages/plugin/src/example.ts"],
"mcp": {
"context7": {
"type": "remote",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/function",
"version": "0.0.1",
"version": "0.3.113",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+5 -3
View File
@@ -1,12 +1,12 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "0.0.0",
"version": "0.3.113",
"name": "opencode",
"type": "module",
"private": true,
"scripts": {
"typecheck": "tsc --noEmit",
"dev": "bun run ./src/index.ts"
"dev": "bun run --conditions=development ./src/index.ts"
},
"bin": {
"opencode": "./bin/opencode"
@@ -30,12 +30,14 @@
"dependencies": {
"@actions/core": "1.11.1",
"@actions/github": "6.0.1",
"@clack/prompts": "0.11.0",
"@clack/prompts": "1.0.0-alpha.1",
"@hono/zod-validator": "0.4.2",
"@modelcontextprotocol/sdk": "1.15.1",
"@octokit/graphql": "9.0.1",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.4.3",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@standard-schema/spec": "1.0.0",
"@zip.js/zip.js": "2.7.62",
"ai": "catalog:",
+2
View File
@@ -2,6 +2,7 @@ import { App } from "../app/app"
import { ConfigHooks } from "../config/hooks"
import { Format } from "../format"
import { LSP } from "../lsp"
import { Plugin } from "../plugin"
import { Share } from "../share/share"
import { Snapshot } from "../snapshot"
@@ -9,6 +10,7 @@ export async function bootstrap<T>(input: App.Input, cb: (app: App.Info) => Prom
return App.provide(input, async (app) => {
Share.init()
Format.init()
Plugin.init()
ConfigHooks.init()
LSP.init()
Snapshot.init()
+1 -1
View File
@@ -39,7 +39,7 @@ const AgentCreateCommand = cmd({
const query = await prompts.text({
message: "Description",
placeholder: "What should this agent do?",
validate: (x) => (x.length > 0 ? undefined : "Required"),
validate: (x) => x && (x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(query)) throw new UI.CancelledError()
+7 -6
View File
@@ -75,6 +75,7 @@ export const AuthLoginCommand = cmd({
type: "string",
}),
async handler(args) {
UI.empty()
prompts.intro("Add credential")
if (args.url) {
const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json())
@@ -99,7 +100,7 @@ export const AuthLoginCommand = cmd({
prompts.outro("Done")
return
}
UI.empty()
await ModelsDev.refresh().catch(() => {})
const providers = await ModelsDev.get()
const priority: Record<string, number> = {
anthropic: 0,
@@ -109,7 +110,7 @@ export const AuthLoginCommand = cmd({
openrouter: 4,
vercel: 5,
}
let provider = await prompts.select({
let provider = await prompts.autocomplete({
message: "Select provider",
maxItems: 8,
options: [
@@ -138,7 +139,7 @@ export const AuthLoginCommand = cmd({
if (provider === "other") {
provider = await prompts.text({
message: "Enter provider id",
validate: (x) => (x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"),
validate: (x) => x && (x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"),
})
if (prompts.isCancel(provider)) throw new UI.CancelledError()
provider = provider.replace(/^@ai-sdk\//, "")
@@ -192,7 +193,7 @@ export const AuthLoginCommand = cmd({
const code = await prompts.text({
message: "Paste the authorization code here: ",
validate: (x) => (x.length > 0 ? undefined : "Required"),
validate: (x) => x && (x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(code)) throw new UI.CancelledError()
@@ -228,7 +229,7 @@ export const AuthLoginCommand = cmd({
const code = await prompts.text({
message: "Paste the authorization code here: ",
validate: (x) => (x.length > 0 ? undefined : "Required"),
validate: (x) => x && (x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(code)) throw new UI.CancelledError()
@@ -301,7 +302,7 @@ export const AuthLoginCommand = cmd({
const key = await prompts.password({
message: "Enter your API key",
validate: (x) => (x.length > 0 ? undefined : "Required"),
validate: (x) => x && (x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(key)) throw new UI.CancelledError()
await Auth.set(provider, {
+7 -5
View File
@@ -318,8 +318,10 @@ on:
jobs:
opencode:
if: |
contains(github.event.comment.body, '/oc') ||
contains(github.event.comment.body, '/opencode')
contains(github.event.comment.body, ' /oc') ||
startsWith(github.event.comment.body, '/oc') ||
contains(github.event.comment.body, ' /opencode') ||
startsWith(github.event.comment.body, '/opencode')
runs-on: ubuntu-latest
permissions:
contents: read
@@ -784,7 +786,7 @@ export const GithubRunCommand = cmd({
console.log("Pushing to new branch...")
await $`git add .`
await $`git commit -m "${summary}
Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
await $`git push -u origin ${branch}`
}
@@ -793,7 +795,7 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
console.log("Pushing to local branch...")
await $`git add .`
await $`git commit -m "${summary}
Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
await $`git push`
}
@@ -805,7 +807,7 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
await $`git add .`
await $`git commit -m "${summary}
Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
await $`git push fork HEAD:${remoteBranch}`
}
+3 -2
View File
@@ -19,7 +19,7 @@ export const McpAddCommand = cmd({
const name = await prompts.text({
message: "Enter MCP server name",
validate: (x) => (x.length > 0 ? undefined : "Required"),
validate: (x) => x && (x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(name)) throw new UI.CancelledError()
@@ -44,7 +44,7 @@ export const McpAddCommand = cmd({
const command = await prompts.text({
message: "Enter command to run",
placeholder: "e.g., opencode x @modelcontextprotocol/server-filesystem",
validate: (x) => (x.length > 0 ? undefined : "Required"),
validate: (x) => x && (x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(command)) throw new UI.CancelledError()
@@ -58,6 +58,7 @@ export const McpAddCommand = cmd({
message: "Enter MCP server URL",
placeholder: "e.g., https://example.com/mcp",
validate: (x) => {
if (!x) return "Required"
if (x.length === 0) return "Required"
const isValid = URL.canParse(x)
return isValid ? undefined : "Invalid URL"
+1 -1
View File
@@ -32,7 +32,7 @@ export const UpgradeCommand = {
return
}
prompts.log.info("Using method: " + method)
const target = args.target ?? (await Installation.latest())
const target = args.target ? args.target.replace(/^v/, "") : await Installation.latest()
if (Installation.VERSION === target) {
prompts.log.warn(`opencode upgrade skipped: ${target} is already installed`)
+77 -28
View File
@@ -1,5 +1,6 @@
import { Log } from "../util/log"
import path from "path"
import os from "os"
import { z } from "zod"
import { App } from "../app/app"
import { Filesystem } from "../util/filesystem"
@@ -23,13 +24,13 @@ export namespace Config {
for (const file of ["opencode.jsonc", "opencode.json"]) {
const found = await Filesystem.findUp(file, app.path.cwd, app.path.root)
for (const resolved of found.toReversed()) {
result = mergeDeep(result, await load(resolved))
result = mergeDeep(result, await loadFile(resolved))
}
}
// Override with custom config if provided
if (Flag.OPENCODE_CONFIG) {
result = mergeDeep(result, await load(Flag.OPENCODE_CONFIG))
result = mergeDeep(result, await loadFile(Flag.OPENCODE_CONFIG))
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
}
@@ -37,7 +38,7 @@ export namespace Config {
if (value.type === "wellknown") {
process.env[value.key] = value.token
const wellknown = await fetch(`${key}/.well-known/opencode`).then((x) => x.json())
result = mergeDeep(result, await loadRaw(JSON.stringify(wellknown.config ?? {}), process.cwd()))
result = mergeDeep(result, await load(JSON.stringify(wellknown.config ?? {}), process.cwd()))
}
}
@@ -144,6 +145,7 @@ export namespace Config {
.object({
model: z.string().optional(),
temperature: z.number().optional(),
top_p: z.number().optional(),
prompt: z.string().optional(),
tools: z.record(z.string(), z.boolean()).optional(),
disable: z.boolean().optional(),
@@ -222,6 +224,7 @@ export namespace Config {
$schema: z.string().optional().describe("JSON schema reference for configuration validation"),
theme: z.string().optional().describe("Theme name to use for the interface"),
keybinds: Keybinds.optional().describe("Custom keybind configurations"),
plugin: z.string().array().optional(),
share: z
.enum(["manual", "auto", "disabled"])
.optional()
@@ -289,6 +292,23 @@ export namespace Config {
}),
)
.optional(),
lsp: z
.record(
z.string(),
z.union([
z.object({
disabled: z.literal(true),
}),
z.object({
command: z.array(z.string()),
extensions: z.array(z.string()).optional(),
disabled: z.boolean().optional(),
env: z.record(z.string(), z.string()).optional(),
initialization: z.record(z.string(), z.any()).optional(),
}),
]),
)
.optional(),
instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"),
layout: Layout.optional().describe("@deprecated Always uses stretch layout."),
permission: z
@@ -334,9 +354,9 @@ export namespace Config {
export const global = lazy(async () => {
let result: Info = pipe(
{},
mergeDeep(await load(path.join(Global.Path.config, "config.json"))),
mergeDeep(await load(path.join(Global.Path.config, "opencode.json"))),
mergeDeep(await load(path.join(Global.Path.config, "opencode.jsonc"))),
mergeDeep(await loadFile(path.join(Global.Path.config, "config.json"))),
mergeDeep(await loadFile(path.join(Global.Path.config, "opencode.json"))),
mergeDeep(await loadFile(path.join(Global.Path.config, "opencode.jsonc"))),
)
await import(path.join(Global.Path.config, "config"), {
@@ -357,46 +377,65 @@ export namespace Config {
return result
})
async function load(configPath: string): Promise<Info> {
let text = await Bun.file(configPath)
async function loadFile(filepath: string): Promise<Info> {
log.info("loading", { path: filepath })
let text = await Bun.file(filepath)
.text()
.catch((err) => {
if (err.code === "ENOENT") return
throw new JsonError({ path: configPath }, { cause: err })
throw new JsonError({ path: filepath }, { cause: err })
})
if (!text) return {}
return loadRaw(text, configPath)
return load(text, filepath)
}
async function loadRaw(text: string, configPath: string) {
async function load(text: string, filepath: string) {
text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
return process.env[varName] || ""
})
const fileMatches = text.match(/"?\{file:([^}]+)\}"?/g)
const fileMatches = text.match(/\{file:[^}]+\}/g)
if (fileMatches) {
const configDir = path.dirname(configPath)
const configDir = path.dirname(filepath)
const lines = text.split("\n")
for (const match of fileMatches) {
const filePath = match.replace(/^"?\{file:/, "").replace(/\}"?$/, "")
const lineIndex = lines.findIndex((line) => line.includes(match))
if (lineIndex !== -1 && lines[lineIndex].trim().startsWith("//")) {
continue // Skip if line is commented
}
let filePath = match.replace(/^\{file:/, "").replace(/\}$/, "")
if (filePath.startsWith("~/")) {
filePath = path.join(os.homedir(), filePath.slice(2))
}
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
const fileContent = await Bun.file(resolvedPath).text()
text = text.replace(match, JSON.stringify(fileContent))
const fileContent = (await Bun.file(resolvedPath).text()).trim()
// escape newlines/quotes, strip outer quotes
text = text.replace(match, JSON.stringify(fileContent).slice(1, -1))
}
}
const errors: JsoncParseError[] = []
const data = parseJsonc(text, errors, { allowTrailingComma: true })
if (errors.length) {
const lines = text.split("\n")
const errorDetails = errors
.map((e) => {
const beforeOffset = text.substring(0, e.offset).split("\n")
const line = beforeOffset.length
const column = beforeOffset[beforeOffset.length - 1].length + 1
const problemLine = lines[line - 1]
const error = `${printParseErrorCode(e.error)} at line ${line}, column ${column}`
if (!problemLine) return error
return `${error}\n Line ${line}: ${problemLine}\n${"".padStart(column + 9)}^`
})
.join("\n")
throw new JsonError({
path: configPath,
message: errors
.map((e) => {
const lines = text.substring(0, e.offset).split("\n")
const line = lines.length
const column = lines[lines.length - 1].length + 1
return `${printParseErrorCode(e.error)} at line ${line}, column ${column}`
})
.join("; "),
path: filepath,
message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${errorDetails}\n--- End ---`,
})
}
@@ -404,11 +443,21 @@ export namespace Config {
if (parsed.success) {
if (!parsed.data.$schema) {
parsed.data.$schema = "https://opencode.ai/config.json"
await Bun.write(configPath, JSON.stringify(parsed.data, null, 2))
await Bun.write(filepath, JSON.stringify(parsed.data, null, 2))
}
return parsed.data
const data = parsed.data
if (data.plugin) {
for (let i = 0; i < data.plugin?.length; i++) {
const plugin = data.plugin[i]
if (typeof plugin === "string") {
data.plugin[i] = path.resolve(path.dirname(filepath), plugin)
}
}
}
return data
}
throw new InvalidError({ path: configPath, issues: parsed.error.issues })
throw new InvalidError({ path: filepath, issues: parsed.error.issues })
}
export const JsonError = NamedError.create(
"ConfigJsonError",
+1 -1
View File
@@ -27,7 +27,7 @@ await Promise.all([
fs.mkdir(Global.Path.log, { recursive: true }),
])
const CACHE_VERSION = "3"
const CACHE_VERSION = "4"
const version = await Bun.file(path.join(Global.Path.cache, "version"))
.text()
@@ -136,6 +136,7 @@ export namespace Installation {
}
export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "dev"
export const USER_AGENT = `opencode/${VERSION}`
export async function latest() {
return fetch("https://api.github.com/repos/sst/opencode/releases/latest")
+29 -1
View File
@@ -4,6 +4,8 @@ import { LSPClient } from "./client"
import path from "path"
import { LSPServer } from "./server"
import { z } from "zod"
import { Config } from "../config/config"
import { spawn } from "child_process"
export namespace LSP {
const log = Log.create({ service: "lsp" })
@@ -55,8 +57,34 @@ export namespace LSP {
"lsp",
async () => {
const clients: LSPClient.Info[] = []
const servers: Record<string, LSPServer.Info> = LSPServer
const cfg = await Config.get()
for (const [name, item] of Object.entries(cfg.lsp ?? {})) {
const existing = servers[name]
if (item.disabled) {
delete servers[name]
continue
}
servers[name] = {
...existing,
extensions: item.extensions ?? existing.extensions,
spawn: async (_app, root) => {
return {
process: spawn(item.command[0], item.command.slice(1), {
cwd: root,
env: {
...process.env,
...item.env,
},
}),
initialization: item.initialization,
}
},
}
}
return {
broken: new Set<string>(),
servers,
clients,
}
},
@@ -76,7 +104,7 @@ export namespace LSP {
const extension = path.parse(file).ext
const result: LSPClient.Info[] = []
for (const server of Object.values(LSPServer)) {
if (!server.extensions.includes(extension)) continue
if (server.extensions.length && !server.extensions.includes(extension)) continue
const root = await server.root(file, App.info())
if (!root) continue
if (s.broken.has(root + server.id)) continue
+2 -1
View File
@@ -115,7 +115,8 @@ export namespace MCP {
const result: Record<string, Tool> = {}
for (const [clientName, client] of Object.entries(await clients())) {
for (const [toolName, tool] of Object.entries(await client.tools())) {
result[clientName + "_" + toolName] = tool
const sanitizedClientName = clientName.replace(/\s+/g, "_")
result[sanitizedClientName + "_" + toolName] = tool
}
}
return result
+14 -1
View File
@@ -3,6 +3,7 @@ import { z } from "zod"
import { Bus } from "../bus"
import { Log } from "../util/log"
import { Identifier } from "../id/id"
import { Plugin } from "../plugin"
export namespace Permission {
const log = Log.create({ service: "permission" })
@@ -67,7 +68,7 @@ export namespace Permission {
},
)
export function ask(input: {
export async function ask(input: {
type: Info["type"]
title: Info["title"]
pattern?: Info["pattern"]
@@ -95,6 +96,18 @@ export namespace Permission {
created: Date.now(),
},
}
switch (
await Plugin.trigger("permission.ask", info, {
status: "ask",
}).then((x) => x.status)
) {
case "deny":
throw new RejectedError(info.sessionID, info.id, info.callID)
case "allow":
return
}
pending[input.sessionID] = pending[input.sessionID] || {}
return new Promise<void>((resolve, reject) => {
pending[input.sessionID][info.id] = {
+85
View File
@@ -0,0 +1,85 @@
import type { Hooks, Plugin as PluginInstance } from "@opencode-ai/plugin"
import { App } from "../app/app"
import { Config } from "../config/config"
import { Bus } from "../bus"
import { Log } from "../util/log"
import { createOpencodeClient } from "@opencode-ai/sdk"
import { Server } from "../server/server"
import { pathOr } from "remeda"
export namespace Plugin {
const log = Log.create({ service: "plugin" })
const state = App.state("plugin", async (app) => {
const client = createOpencodeClient({
baseUrl: "http://localhost:4096",
fetch: async (...args) => Server.app().fetch(...args),
})
const config = await Config.get()
const hooks = []
for (const plugin of config.plugin ?? []) {
log.info("loading plugin", { path: plugin })
const mod = await import(plugin)
for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
const init = await fn({
client,
app,
$: Bun.$,
})
hooks.push(init)
}
}
return {
hooks,
}
})
type Path<T, Prefix extends string = ""> = T extends object
? {
[K in keyof T]: K extends string
? T[K] extends Function | undefined
? `${Prefix}${K}`
: Path<T[K], `${Prefix}${K}.`>
: never
}[keyof T]
: never
export type FunctionFromKey<T, P extends Path<T>> = P extends `${infer K}.${infer R}`
? K extends keyof T
? R extends Path<T[K]>
? FunctionFromKey<T[K], R>
: never
: never
: P extends keyof T
? T[P]
: never
export async function trigger<
Name extends Path<Required<Hooks>>,
Input = Parameters<FunctionFromKey<Required<Hooks>, Name>>[0],
Output = Parameters<FunctionFromKey<Required<Hooks>, Name>>[1],
>(fn: Name, input: Input, output: Output): Promise<Output> {
if (!fn) return output
const path = fn.split(".")
for (const hook of await state().then((x) => x.hooks)) {
// @ts-expect-error
const fn = pathOr(hook, path, undefined)
if (!fn) continue
// @ts-expect-error
await fn(input, output)
}
return output
}
export function init() {
Bus.subscribeAll(async (input) => {
const hooks = await state().then((x) => x.hooks)
for (const hook of hooks) {
hook["event"]?.({
event: input,
})
}
})
}
}
+19 -9
View File
@@ -3,6 +3,7 @@ import { Log } from "../util/log"
import path from "path"
import { z } from "zod"
import { data } from "./models-macro" with { type: "macro" }
import { Installation } from "../installation"
export namespace ModelsDev {
const log = Log.create({ service: "models.dev" })
@@ -50,21 +51,30 @@ export namespace ModelsDev {
export type Provider = z.infer<typeof Provider>
export async function get() {
refresh()
const file = Bun.file(filepath)
const result = await file.json().catch(() => {})
if (result) {
refresh()
return result as Record<string, Provider>
}
refresh()
if (result) return result as Record<string, Provider>
const json = await data()
return JSON.parse(json) as Record<string, Provider>
}
async function refresh() {
export async function refresh() {
const file = Bun.file(filepath)
log.info("refreshing")
const result = await fetch("https://models.dev/api.json").catch(() => {})
if (result && result.ok) await Bun.write(file, result)
log.info("refreshing", {
file,
})
const result = await fetch("https://models.dev/api.json", {
headers: {
"User-Agent": Installation.USER_AGENT,
},
}).catch((e) => {
log.error("Failed to fetch models.dev", {
error: e,
})
})
if (result && result.ok) await Bun.write(file, await result.text())
}
}
setInterval(() => ModelsDev.refresh(), 60 * 1000).unref()
+18 -14
View File
@@ -97,7 +97,7 @@ export namespace Provider {
Array.isArray(msg.content) && msg.content.some((part: any) => part.type === "image_url"),
)
}
} catch { }
} catch {}
const headers: Record<string, string> = {
...init.headers,
...copilot.HEADERS,
@@ -283,26 +283,26 @@ export namespace Provider {
cost:
!model.cost && !existing?.cost
? {
input: 0,
output: 0,
cache_read: 0,
cache_write: 0,
}
input: 0,
output: 0,
cache_read: 0,
cache_write: 0,
}
: {
cache_read: 0,
cache_write: 0,
...existing?.cost,
...model.cost,
},
cache_read: 0,
cache_write: 0,
...existing?.cost,
...model.cost,
},
options: {
...existing?.options,
...model.options,
},
limit: model.limit ??
existing?.limit ?? {
context: 0,
output: 0,
},
context: 0,
output: 0,
},
}
parsed.models[modelID] = parsedModel
}
@@ -386,6 +386,10 @@ export namespace Provider {
})
}
export async function getProvider(providerID: string) {
return state().then((s) => s.providers[providerID])
}
export async function getModel(providerID: string, modelID: string) {
const key = `${providerID}/${modelID}`
const s = await state()
+67 -36
View File
@@ -2,47 +2,73 @@ import type { ModelMessage } from "ai"
import { unique } from "remeda"
export namespace ProviderTransform {
export function message(msgs: ModelMessage[], providerID: string, modelID: string) {
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)
function normalizeToolCallIds(msgs: ModelMessage[]): ModelMessage[] {
return msgs.map((msg) => {
if ((msg.role === "assistant" || msg.role === "tool") && Array.isArray(msg.content)) {
msg.content = msg.content.map((part) => {
if ((part.type === "tool-call" || part.type === "tool-result") && "toolCallId" in part) {
return {
...part,
toolCallId: part.toolCallId.replace(/[^a-zA-Z0-9_-]/g, "_"),
}
}
return part
})
}
return msg
})
}
const providerOptions = {
anthropic: {
cacheControl: { type: "ephemeral" },
},
openrouter: {
cache_control: { type: "ephemeral" },
},
bedrock: {
cachePoint: { type: "ephemeral" },
},
openaiCompatible: {
cache_control: { type: "ephemeral" },
},
function applyCaching(msgs: ModelMessage[], providerID: string): ModelMessage[] {
const system = msgs.filter((msg) => msg.role === "system").slice(0, 2)
const final = msgs.filter((msg) => msg.role !== "system").slice(-2)
const providerOptions = {
anthropic: {
cacheControl: { type: "ephemeral" },
},
openrouter: {
cache_control: { type: "ephemeral" },
},
bedrock: {
cachePoint: { type: "ephemeral" },
},
openaiCompatible: {
cache_control: { type: "ephemeral" },
},
}
for (const msg of unique([...system, ...final])) {
const shouldUseContentOptions = providerID !== "anthropic" && Array.isArray(msg.content) && msg.content.length > 0
if (shouldUseContentOptions) {
const lastContent = msg.content[msg.content.length - 1]
if (lastContent && typeof lastContent === "object") {
lastContent.providerOptions = {
...lastContent.providerOptions,
...providerOptions,
}
continue
}
}
for (const msg of unique([...system, ...final])) {
const shouldUseContentOptions =
providerID !== "anthropic" && Array.isArray(msg.content) && msg.content.length > 0
if (shouldUseContentOptions) {
const lastContent = msg.content[msg.content.length - 1]
if (lastContent && typeof lastContent === "object") {
lastContent.providerOptions = {
...lastContent.providerOptions,
...providerOptions,
}
continue
}
}
msg.providerOptions = {
...msg.providerOptions,
...providerOptions,
}
msg.providerOptions = {
...msg.providerOptions,
...providerOptions,
}
}
return msgs
}
export function message(msgs: ModelMessage[], providerID: string, modelID: string) {
if (modelID.includes("claude")) {
msgs = normalizeToolCallIds(msgs)
}
if (providerID === "anthropic" || modelID.includes("anthropic") || modelID.includes("claude")) {
msgs = applyCaching(msgs, providerID)
}
return msgs
}
@@ -50,4 +76,9 @@ export namespace ProviderTransform {
if (modelID.toLowerCase().includes("qwen")) return 0.55
return 0
}
export function topP(_providerID: string, modelID: string) {
if (modelID.toLowerCase().includes("qwen")) return 1
return undefined
}
}
+3 -2
View File
@@ -19,6 +19,7 @@ import { MessageV2 } from "../session/message-v2"
import { Mode } from "../session/mode"
import { callTui, TuiRoute } from "./tui"
import { Permission } from "../permission"
import { lazy } from "../util/lazy"
const ERRORS = {
400: {
@@ -48,7 +49,7 @@ export namespace Server {
Connected: Bus.event("server.connected", z.object({})),
}
function app() {
export const app = lazy(() => {
const app = new Hono()
const result = app
@@ -1022,7 +1023,7 @@ export namespace Server {
.route("/tui/control", TuiRoute)
return result
}
})
export async function openapi() {
const a = app()
+47 -5
View File
@@ -41,6 +41,7 @@ import { LSP } from "../lsp"
import { ReadTool } from "../tool/read"
import { mergeDeep, pipe, splitWhen } from "remeda"
import { ToolRegistry } from "../tool/registry"
import { Plugin } from "../plugin"
export namespace Session {
const log = Log.create({ service: "session" })
@@ -571,7 +572,14 @@ export namespace Session {
text: PROMPT_PLAN,
synthetic: true,
})
await Plugin.trigger(
"chat.message",
{},
{
message: userMsg,
parts: userParts,
},
)
await updateMessage(userMsg)
for (const part of userParts) {
await updatePart(part)
@@ -716,6 +724,17 @@ export namespace Session {
description: item.description,
inputSchema: item.parameters as ZodSchema,
async execute(args, options) {
await Plugin.trigger(
"tool.execute.before",
{
tool: item.id,
sessionID: input.sessionID,
callID: options.toolCallId,
},
{
args,
},
)
await processor.track(options.toolCallId)
const result = await item.execute(args, {
sessionID: input.sessionID,
@@ -740,6 +759,15 @@ export namespace Session {
}
},
})
await Plugin.trigger(
"tool.execute.after",
{
tool: item.id,
sessionID: input.sessionID,
callID: options.toolCallId,
},
result,
)
return result
},
toModelOutput(result) {
@@ -776,6 +804,21 @@ export namespace Session {
tools[key] = item
}
const params = {
temperature: model.info.temperature
? (mode.temperature ?? ProviderTransform.temperature(input.providerID, input.modelID))
: undefined,
topP: mode.topP ?? ProviderTransform.topP(input.providerID, input.modelID),
}
await Plugin.trigger(
"chat.params",
{
model: model.info,
provider: await Provider.getProvider(input.providerID),
message: userMsg,
},
params,
)
const stream = streamText({
onError(e) {
log.error("streamText error", {
@@ -835,6 +878,8 @@ export namespace Session {
providerOptions: {
[input.providerID]: model.info.options,
},
temperature: params.temperature,
topP: params.topP,
messages: [
...system.map(
(x): ModelMessage => ({
@@ -844,9 +889,6 @@ export namespace Session {
),
...MessageV2.toModelMessage(msgs),
],
temperature: model.info.temperature
? (mode.temperature ?? ProviderTransform.temperature(input.providerID, input.modelID))
: undefined,
tools: model.info.tool_call === false ? undefined : tools,
model: wrapLanguageModel({
model: model.language,
@@ -1115,7 +1157,7 @@ export namespace Session {
}
const p = await getParts(assistantMsg.sessionID, assistantMsg.id)
for (const part of p) {
if (part.type === "tool" && part.state.status !== "completed") {
if (part.type === "tool" && part.state.status !== "completed" && part.state.status !== "error") {
updatePart({
...part,
state: {
+3 -1
View File
@@ -8,6 +8,7 @@ export namespace Mode {
.object({
name: z.string(),
temperature: z.number().optional(),
topP: z.number().optional(),
model: z
.object({
modelID: z.string(),
@@ -51,7 +52,8 @@ export namespace Mode {
item.name = key
if (value.model) item.model = Provider.parseModel(value.model)
if (value.prompt) item.prompt = value.prompt
if (value.temperature) item.temperature = value.temperature
if (value.temperature != undefined) item.temperature = value.temperature
if (value.top_p != undefined) item.topP = value.top_p
if (value.tools)
item.tools = {
...value.tools,
+17 -8
View File
@@ -5,14 +5,17 @@ import { App } from "../app/app"
import { Permission } from "../permission"
import { Config } from "../config/config"
import { Filesystem } from "../util/filesystem"
import path from "path"
import { lazy } from "../util/lazy"
import { minimatch } from "minimatch"
import { Log } from "../util/log"
import { Wildcard } from "../util/wildcard"
import { $ } from "bun"
const MAX_OUTPUT_LENGTH = 30000
const DEFAULT_TIMEOUT = 1 * 60 * 1000
const MAX_TIMEOUT = 10 * 60 * 1000
const log = Log.create({ service: "bash-tool" })
const parser = lazy(async () => {
const { default: Parser } = await import("tree-sitter")
const Bash = await import("tree-sitter-bash")
@@ -71,9 +74,14 @@ export const BashTool = Tool.define("bash", {
// not an exhaustive list, but covers most common cases
if (["cd", "rm", "cp", "mv", "mkdir", "touch", "chmod", "chown"].includes(command[0])) {
for (const arg of command.slice(1)) {
if (arg.startsWith("-")) continue
const resolved = path.resolve(app.path.cwd, arg)
if (!Filesystem.contains(app.path.cwd, resolved)) {
if (arg.startsWith("-") || (command[0] === "chmod" && arg.startsWith("+"))) continue
const resolved = await $`realpath ${arg}`
.quiet()
.nothrow()
.text()
.then((x) => x.trim())
log.info("resolved path", { arg, resolved })
if (resolved && !Filesystem.contains(app.path.cwd, resolved)) {
throw new Error(
`This command references paths outside of ${app.path.cwd} so it is not allowed to be executed.`,
)
@@ -85,9 +93,9 @@ export const BashTool = Tool.define("bash", {
if (!needsAsk && command[0] !== "cd") {
const ask = (() => {
for (const [pattern, value] of Object.entries(permissions)) {
if (minimatch(node.text, pattern)) {
return value
}
const match = Wildcard.match(node.text, pattern)
log.info("checking", { text: node.text.trim(), pattern, match })
if (match) return value
}
return "ask"
})()
@@ -114,6 +122,7 @@ export const BashTool = Tool.define("bash", {
maxBuffer: MAX_OUTPUT_LENGTH,
signal: ctx.abort,
timeout: timeout,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
})
+14
View File
@@ -0,0 +1,14 @@
export namespace Wildcard {
export function match(str: string, pattern: string) {
const regex = new RegExp(
"^" +
pattern
.replace(/[.+^${}()|[\]\\]/g, "\\$&") // escape special regex chars
.replace(/\*/g, ".*") // * becomes .*
.replace(/\?/g, ".") + // ? becomes .
"$",
"s", // s flag enables multiline matching
)
return regex.test(str)
}
}
+8 -6
View File
@@ -19,19 +19,21 @@ Log.init({ print: false })
describe("tool.bash", () => {
test("basic", async () => {
await App.provide({ cwd: projectRoot }, async () => {
await bash.execute(
const result = await bash.execute(
{
command: "cd foo/bar && ls",
description: "List files in foo/bar",
command: "echo 'test'",
description: "Echo test message",
},
ctx,
)
expect(result.metadata.exit).toBe(0)
expect(result.metadata.stdout).toContain("test")
})
})
test("cd ../ should fail", async () => {
test("cd ../ should fail outside of project root", async () => {
await App.provide({ cwd: projectRoot }, async () => {
expect(
await expect(
bash.execute(
{
command: "cd ../",
@@ -39,7 +41,7 @@ describe("tool.bash", () => {
},
ctx,
),
).rejects.toThrow()
).rejects.toThrow("This command references paths outside of")
})
})
})
+10 -1
View File
@@ -1,5 +1,14 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {}
"compilerOptions": {
"lib": [
"ESNext",
"DOM",
"DOM.Iterable"
],
"customConditions": [
"development"
]
}
}
+1
View File
@@ -0,0 +1 @@
dist
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "0.3.113",
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit"
},
"exports": {
".": "./src/index.ts"
},
"files": [
"dist"
],
"devDependencies": {
"typescript": "catalog:",
"@hey-api/openapi-ts": "0.80.1",
"@tsconfig/node22": "catalog:",
"@opencode-ai/sdk": "workspace:*"
}
}
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bun
const dir = new URL("..", import.meta.url).pathname
process.chdir(dir)
import { $ } from "bun"
const snapshot = process.env["OPENCODE_SNAPSHOT"] === "true"
await $`bun tsc`
if (snapshot) {
await $`bun publish --tag snapshot --access public`
await $`git checkout package.json`
}
if (!snapshot) {
await $`bun publish --access public`
}
+7
View File
@@ -0,0 +1,7 @@
import { Plugin } from "./index"
export const ExamplePlugin: Plugin = async ({ app, client }) => {
return {
permission: {},
}
}
+56
View File
@@ -0,0 +1,56 @@
import type { Event, createOpencodeClient, App, Model, Provider, Permission, UserMessage, Part } from "@opencode-ai/sdk"
import { $ } from "bun"
export type PluginInput = {
client: ReturnType<typeof createOpencodeClient>
app: App
$: $
}
export type Plugin = (input: PluginInput) => Promise<Hooks>
export interface Hooks {
event?: (input: { event: Event }) => Promise<void>
chat?: {
/**
* Called when a new message is received
*/
message?: (input: {}, output: { message: UserMessage; parts: Part[] }) => Promise<void>
/**
* Modify parameters sent to LLM
*/
params?: (
input: { model: Model; provider: Provider; message: UserMessage },
output: { temperature: number; topP: number },
) => Promise<void>
}
permission?: {
/**
* Called when a permission is asked
*/
ask?: (input: Permission, output: { status: "ask" | "deny" | "allow" }) => Promise<void>
}
tool?: {
execute?: {
/**
* Called before a tool is executed
*/
before?: (
input: { tool: string; sessionID: string; callID: string },
output: {
args: any
},
) => Promise<void>
/**
* Called after a tool is executed
*/
after?: (
input: { tool: string; sessionID: string; callID: string },
output: {
title: string
output: string
metadata: any
},
) => Promise<void>
}
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"$schema": "https://json.schemastore.org/tsconfig.json",
"extends": "@tsconfig/node22/tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"module": "preserve",
"declaration": true,
"moduleResolution": "bundler",
"customConditions": [
"development"
]
},
"include": [
"src"
]
}
+8 -2
View File
@@ -1,10 +1,16 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "0.0.0",
"version": "0.3.113",
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit"
},
"exports": {
".": "./dist/index.js"
".": {
"development": "./src/index.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
+2 -6
View File
@@ -4,7 +4,6 @@ const dir = new URL("..", import.meta.url).pathname
process.chdir(dir)
import { $ } from "bun"
import fs from "fs/promises"
import path from "path"
console.log("=== Generating JS SDK ===")
@@ -12,8 +11,7 @@ console.log()
import { createClient } from "@hey-api/openapi-ts"
await fs.rm(path.join(dir, "src/gen"), { recursive: true, force: true })
await $`bun run ../../opencode/src/index.ts generate > openapi.json`
await $`bun dev generate > ${dir}/openapi.json`.cwd(path.resolve(dir, "../../opencode"))
await createClient({
input: "./openapi.json",
@@ -36,6 +34,4 @@ await createClient({
},
],
})
await $`rm -rf dist`
await $`bun tsc`
await $`bun prettier --write src/gen`
+2 -7
View File
@@ -5,20 +5,15 @@ process.chdir(dir)
import { $ } from "bun"
const version = process.env["OPENCODE_VERSION"]
if (!version) {
throw new Error("OPENCODE_VERSION is required")
}
await import("./generate")
await $`rm -rf dist`
await $`bun tsc`
const snapshot = process.env["OPENCODE_SNAPSHOT"] === "true"
await $`bun pm version --allow-same-version --no-git-tag-version ${version}`
if (snapshot) {
await $`bun publish --tag snapshot`
}
if (!snapshot) {
await $`bun publish`
}
await $`bun pm version 0.0.0 --no-git-tag-version`
+10 -6
View File
@@ -1,7 +1,7 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { ClientOptions } from './types.gen';
import { type Config, type ClientOptions as DefaultClientOptions, createClient, createConfig } from './client';
import type { ClientOptions } from "./types.gen"
import { type Config, type ClientOptions as DefaultClientOptions, createClient, createConfig } from "./client"
/**
* The `createClientConfig()` function will be called on client initialization
@@ -11,8 +11,12 @@ import { type Config, type ClientOptions as DefaultClientOptions, createClient,
* `setConfig()`. This is useful for example if you're using Next.js
* to ensure your client always has the correct values.
*/
export type CreateClientConfig<T extends DefaultClientOptions = ClientOptions> = (override?: Config<DefaultClientOptions & T>) => Config<Required<DefaultClientOptions> & T>;
export type CreateClientConfig<T extends DefaultClientOptions = ClientOptions> = (
override?: Config<DefaultClientOptions & T>,
) => Config<Required<DefaultClientOptions> & T>
export const client = createClient(createConfig<ClientOptions>({
baseUrl: 'http://localhost:4096'
}));
export const client = createClient(
createConfig<ClientOptions>({
baseUrl: "http://localhost:4096",
}),
)
+70 -80
View File
@@ -1,4 +1,4 @@
import type { Client, Config, RequestOptions } from './types';
import type { Client, Config, RequestOptions } from "./types"
import {
buildUrl,
createConfig,
@@ -7,189 +7,179 @@ import {
mergeConfigs,
mergeHeaders,
setAuthParams,
} from './utils';
} from "./utils"
type ReqInit = Omit<RequestInit, 'body' | 'headers'> & {
body?: any;
headers: ReturnType<typeof mergeHeaders>;
};
type ReqInit = Omit<RequestInit, "body" | "headers"> & {
body?: any
headers: ReturnType<typeof mergeHeaders>
}
export const createClient = (config: Config = {}): Client => {
let _config = mergeConfigs(createConfig(), config);
let _config = mergeConfigs(createConfig(), config)
const getConfig = (): Config => ({ ..._config });
const getConfig = (): Config => ({ ..._config })
const setConfig = (config: Config): Config => {
_config = mergeConfigs(_config, config);
return getConfig();
};
_config = mergeConfigs(_config, config)
return getConfig()
}
const interceptors = createInterceptors<
Request,
Response,
unknown,
RequestOptions
>();
const interceptors = createInterceptors<Request, Response, unknown, RequestOptions>()
const request: Client['request'] = async (options) => {
const request: Client["request"] = async (options) => {
const opts = {
..._config,
...options,
fetch: options.fetch ?? _config.fetch ?? globalThis.fetch,
headers: mergeHeaders(_config.headers, options.headers),
};
}
if (opts.security) {
await setAuthParams({
...opts,
security: opts.security,
});
})
}
if (opts.requestValidator) {
await opts.requestValidator(opts);
await opts.requestValidator(opts)
}
if (opts.body && opts.bodySerializer) {
opts.body = opts.bodySerializer(opts.body);
opts.body = opts.bodySerializer(opts.body)
}
// remove Content-Type header if body is empty to avoid sending invalid requests
if (opts.body === undefined || opts.body === '') {
opts.headers.delete('Content-Type');
if (opts.body === undefined || opts.body === "") {
opts.headers.delete("Content-Type")
}
const url = buildUrl(opts);
const url = buildUrl(opts)
const requestInit: ReqInit = {
redirect: 'follow',
redirect: "follow",
...opts,
};
}
let request = new Request(url, requestInit);
let request = new Request(url, requestInit)
for (const fn of interceptors.request._fns) {
if (fn) {
request = await fn(request, opts);
request = await fn(request, opts)
}
}
// fetch must be assigned here, otherwise it would throw the error:
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
const _fetch = opts.fetch!;
let response = await _fetch(request);
const _fetch = opts.fetch!
let response = await _fetch(request)
for (const fn of interceptors.response._fns) {
if (fn) {
response = await fn(response, request, opts);
response = await fn(response, request, opts)
}
}
const result = {
request,
response,
};
}
if (response.ok) {
if (
response.status === 204 ||
response.headers.get('Content-Length') === '0'
) {
return opts.responseStyle === 'data'
if (response.status === 204 || response.headers.get("Content-Length") === "0") {
return opts.responseStyle === "data"
? {}
: {
data: {},
...result,
};
}
}
const parseAs =
(opts.parseAs === 'auto'
? getParseAs(response.headers.get('Content-Type'))
: opts.parseAs) ?? 'json';
(opts.parseAs === "auto" ? getParseAs(response.headers.get("Content-Type")) : opts.parseAs) ?? "json"
let data: any;
let data: any
switch (parseAs) {
case 'arrayBuffer':
case 'blob':
case 'formData':
case 'json':
case 'text':
data = await response[parseAs]();
break;
case 'stream':
return opts.responseStyle === 'data'
case "arrayBuffer":
case "blob":
case "formData":
case "json":
case "text":
data = await response[parseAs]()
break
case "stream":
return opts.responseStyle === "data"
? response.body
: {
data: response.body,
...result,
};
}
}
if (parseAs === 'json') {
if (parseAs === "json") {
if (opts.responseValidator) {
await opts.responseValidator(data);
await opts.responseValidator(data)
}
if (opts.responseTransformer) {
data = await opts.responseTransformer(data);
data = await opts.responseTransformer(data)
}
}
return opts.responseStyle === 'data'
return opts.responseStyle === "data"
? data
: {
data,
...result,
};
}
}
const textError = await response.text();
let jsonError: unknown;
const textError = await response.text()
let jsonError: unknown
try {
jsonError = JSON.parse(textError);
jsonError = JSON.parse(textError)
} catch {
// noop
}
const error = jsonError ?? textError;
let finalError = error;
const error = jsonError ?? textError
let finalError = error
for (const fn of interceptors.error._fns) {
if (fn) {
finalError = (await fn(error, response, request, opts)) as string;
finalError = (await fn(error, response, request, opts)) as string
}
}
finalError = finalError || ({} as string);
finalError = finalError || ({} as string)
if (opts.throwOnError) {
throw finalError;
throw finalError
}
// TODO: we probably want to return error and improve types
return opts.responseStyle === 'data'
return opts.responseStyle === "data"
? undefined
: {
error: finalError,
...result,
};
};
}
}
return {
buildUrl,
connect: (options) => request({ ...options, method: 'CONNECT' }),
delete: (options) => request({ ...options, method: 'DELETE' }),
get: (options) => request({ ...options, method: 'GET' }),
connect: (options) => request({ ...options, method: "CONNECT" }),
delete: (options) => request({ ...options, method: "DELETE" }),
get: (options) => request({ ...options, method: "GET" }),
getConfig,
head: (options) => request({ ...options, method: 'HEAD' }),
head: (options) => request({ ...options, method: "HEAD" }),
interceptors,
options: (options) => request({ ...options, method: 'OPTIONS' }),
patch: (options) => request({ ...options, method: 'PATCH' }),
post: (options) => request({ ...options, method: 'POST' }),
put: (options) => request({ ...options, method: 'PUT' }),
options: (options) => request({ ...options, method: "OPTIONS" }),
patch: (options) => request({ ...options, method: "PATCH" }),
post: (options) => request({ ...options, method: "POST" }),
put: (options) => request({ ...options, method: "PUT" }),
request,
setConfig,
trace: (options) => request({ ...options, method: 'TRACE' }),
};
};
trace: (options) => request({ ...options, method: "TRACE" }),
}
}
+7 -11
View File
@@ -1,12 +1,8 @@
export type { Auth } from '../core/auth';
export type { QuerySerializerOptions } from '../core/bodySerializer';
export {
formDataBodySerializer,
jsonBodySerializer,
urlSearchParamsBodySerializer,
} from '../core/bodySerializer';
export { buildClientParams } from '../core/params';
export { createClient } from './client';
export type { Auth } from "../core/auth"
export type { QuerySerializerOptions } from "../core/bodySerializer"
export { formDataBodySerializer, jsonBodySerializer, urlSearchParamsBodySerializer } from "../core/bodySerializer"
export { buildClientParams } from "../core/params"
export { createClient } from "./client"
export type {
Client,
ClientOptions,
@@ -18,5 +14,5 @@ export type {
RequestResult,
ResponseStyle,
TDataShape,
} from './types';
export { createConfig, mergeHeaders } from './utils';
} from "./types"
export { createConfig, mergeHeaders } from "./utils"
+67 -98
View File
@@ -1,33 +1,30 @@
import type { Auth } from '../core/auth';
import type {
Client as CoreClient,
Config as CoreConfig,
} from '../core/types';
import type { Middleware } from './utils';
import type { Auth } from "../core/auth"
import type { Client as CoreClient, Config as CoreConfig } from "../core/types"
import type { Middleware } from "./utils"
export type ResponseStyle = 'data' | 'fields';
export type ResponseStyle = "data" | "fields"
export interface Config<T extends ClientOptions = ClientOptions>
extends Omit<RequestInit, 'body' | 'headers' | 'method'>,
extends Omit<RequestInit, "body" | "headers" | "method">,
CoreConfig {
/**
* Base URL for all requests made by this client.
*/
baseUrl?: T['baseUrl'];
baseUrl?: T["baseUrl"]
/**
* Fetch API implementation. You can use this option to provide a custom
* fetch instance.
*
* @default globalThis.fetch
*/
fetch?: (request: Request) => ReturnType<typeof fetch>;
fetch?: (request: Request) => ReturnType<typeof fetch>
/**
* Please don't use the Fetch client for Next.js applications. The `next`
* options won't have any effect.
*
* Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead.
*/
next?: never;
next?: never
/**
* Return the response data parsed in a specified format. By default, `auto`
* will infer the appropriate method from the `Content-Type` response header.
@@ -36,135 +33,118 @@ export interface Config<T extends ClientOptions = ClientOptions>
*
* @default 'auto'
*/
parseAs?:
| 'arrayBuffer'
| 'auto'
| 'blob'
| 'formData'
| 'json'
| 'stream'
| 'text';
parseAs?: "arrayBuffer" | "auto" | "blob" | "formData" | "json" | "stream" | "text"
/**
* Should we return only data or multiple fields (data, error, response, etc.)?
*
* @default 'fields'
*/
responseStyle?: ResponseStyle;
responseStyle?: ResponseStyle
/**
* Throw an error instead of returning it in the response?
*
* @default false
*/
throwOnError?: T['throwOnError'];
throwOnError?: T["throwOnError"]
}
export interface RequestOptions<
TResponseStyle extends ResponseStyle = 'fields',
TResponseStyle extends ResponseStyle = "fields",
ThrowOnError extends boolean = boolean,
Url extends string = string,
> extends Config<{
responseStyle: TResponseStyle;
throwOnError: ThrowOnError;
responseStyle: TResponseStyle
throwOnError: ThrowOnError
}> {
/**
* Any body that you want to add to your request.
*
* {@link https://developer.mozilla.org/docs/Web/API/fetch#body}
*/
body?: unknown;
path?: Record<string, unknown>;
query?: Record<string, unknown>;
body?: unknown
path?: Record<string, unknown>
query?: Record<string, unknown>
/**
* Security mechanism(s) to use for the request.
*/
security?: ReadonlyArray<Auth>;
url: Url;
security?: ReadonlyArray<Auth>
url: Url
}
export type RequestResult<
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = boolean,
TResponseStyle extends ResponseStyle = 'fields',
TResponseStyle extends ResponseStyle = "fields",
> = ThrowOnError extends true
? Promise<
TResponseStyle extends 'data'
TResponseStyle extends "data"
? TData extends Record<string, unknown>
? TData[keyof TData]
: TData
: {
data: TData extends Record<string, unknown>
? TData[keyof TData]
: TData;
request: Request;
response: Response;
data: TData extends Record<string, unknown> ? TData[keyof TData] : TData
request: Request
response: Response
}
>
: Promise<
TResponseStyle extends 'data'
?
| (TData extends Record<string, unknown>
? TData[keyof TData]
: TData)
| undefined
TResponseStyle extends "data"
? (TData extends Record<string, unknown> ? TData[keyof TData] : TData) | undefined
: (
| {
data: TData extends Record<string, unknown>
? TData[keyof TData]
: TData;
error: undefined;
data: TData extends Record<string, unknown> ? TData[keyof TData] : TData
error: undefined
}
| {
data: undefined;
error: TError extends Record<string, unknown>
? TError[keyof TError]
: TError;
data: undefined
error: TError extends Record<string, unknown> ? TError[keyof TError] : TError
}
) & {
request: Request;
response: Response;
request: Request
response: Response
}
>;
>
export interface ClientOptions {
baseUrl?: string;
responseStyle?: ResponseStyle;
throwOnError?: boolean;
baseUrl?: string
responseStyle?: ResponseStyle
throwOnError?: boolean
}
type MethodFn = <
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = false,
TResponseStyle extends ResponseStyle = 'fields',
TResponseStyle extends ResponseStyle = "fields",
>(
options: Omit<RequestOptions<TResponseStyle, ThrowOnError>, 'method'>,
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
options: Omit<RequestOptions<TResponseStyle, ThrowOnError>, "method">,
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>
type RequestFn = <
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = false,
TResponseStyle extends ResponseStyle = 'fields',
TResponseStyle extends ResponseStyle = "fields",
>(
options: Omit<RequestOptions<TResponseStyle, ThrowOnError>, 'method'> &
Pick<Required<RequestOptions<TResponseStyle, ThrowOnError>>, 'method'>,
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
options: Omit<RequestOptions<TResponseStyle, ThrowOnError>, "method"> &
Pick<Required<RequestOptions<TResponseStyle, ThrowOnError>>, "method">,
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>
type BuildUrlFn = <
TData extends {
body?: unknown;
path?: Record<string, unknown>;
query?: Record<string, unknown>;
url: string;
body?: unknown
path?: Record<string, unknown>
query?: Record<string, unknown>
url: string
},
>(
options: Pick<TData, 'url'> & Options<TData>,
) => string;
options: Pick<TData, "url"> & Options<TData>,
) => string
export type Client = CoreClient<RequestFn, Config, MethodFn, BuildUrlFn> & {
interceptors: Middleware<Request, Response, unknown, RequestOptions>;
};
interceptors: Middleware<Request, Response, unknown, RequestOptions>
}
/**
* The `createClientConfig()` function will be called on client initialization
@@ -176,47 +156,36 @@ export type Client = CoreClient<RequestFn, Config, MethodFn, BuildUrlFn> & {
*/
export type CreateClientConfig<T extends ClientOptions = ClientOptions> = (
override?: Config<ClientOptions & T>,
) => Config<Required<ClientOptions> & T>;
) => Config<Required<ClientOptions> & T>
export interface TDataShape {
body?: unknown;
headers?: unknown;
path?: unknown;
query?: unknown;
url: string;
body?: unknown
headers?: unknown
path?: unknown
query?: unknown
url: string
}
type OmitKeys<T, K> = Pick<T, Exclude<keyof T, K>>;
type OmitKeys<T, K> = Pick<T, Exclude<keyof T, K>>
export type Options<
TData extends TDataShape = TDataShape,
ThrowOnError extends boolean = boolean,
TResponseStyle extends ResponseStyle = 'fields',
> = OmitKeys<
RequestOptions<TResponseStyle, ThrowOnError>,
'body' | 'path' | 'query' | 'url'
> &
Omit<TData, 'url'>;
TResponseStyle extends ResponseStyle = "fields",
> = OmitKeys<RequestOptions<TResponseStyle, ThrowOnError>, "body" | "path" | "query" | "url"> & Omit<TData, "url">
export type OptionsLegacyParser<
TData = unknown,
ThrowOnError extends boolean = boolean,
TResponseStyle extends ResponseStyle = 'fields',
TResponseStyle extends ResponseStyle = "fields",
> = TData extends { body?: any }
? TData extends { headers?: any }
? OmitKeys<
RequestOptions<TResponseStyle, ThrowOnError>,
'body' | 'headers' | 'url'
> &
TData
: OmitKeys<RequestOptions<TResponseStyle, ThrowOnError>, 'body' | 'url'> &
? OmitKeys<RequestOptions<TResponseStyle, ThrowOnError>, "body" | "headers" | "url"> & TData
: OmitKeys<RequestOptions<TResponseStyle, ThrowOnError>, "body" | "url"> &
TData &
Pick<RequestOptions<TResponseStyle, ThrowOnError>, 'headers'>
Pick<RequestOptions<TResponseStyle, ThrowOnError>, "headers">
: TData extends { headers?: any }
? OmitKeys<
RequestOptions<TResponseStyle, ThrowOnError>,
'headers' | 'url'
> &
? OmitKeys<RequestOptions<TResponseStyle, ThrowOnError>, "headers" | "url"> &
TData &
Pick<RequestOptions<TResponseStyle, ThrowOnError>, 'body'>
: OmitKeys<RequestOptions<TResponseStyle, ThrowOnError>, 'url'> & TData;
Pick<RequestOptions<TResponseStyle, ThrowOnError>, "body">
: OmitKeys<RequestOptions<TResponseStyle, ThrowOnError>, "url"> & TData
+154 -198
View File
@@ -1,64 +1,54 @@
import { getAuthToken } from '../core/auth';
import type {
QuerySerializer,
QuerySerializerOptions,
} from '../core/bodySerializer';
import { jsonBodySerializer } from '../core/bodySerializer';
import {
serializeArrayParam,
serializeObjectParam,
serializePrimitiveParam,
} from '../core/pathSerializer';
import type { Client, ClientOptions, Config, RequestOptions } from './types';
import { getAuthToken } from "../core/auth"
import type { QuerySerializer, QuerySerializerOptions } from "../core/bodySerializer"
import { jsonBodySerializer } from "../core/bodySerializer"
import { serializeArrayParam, serializeObjectParam, serializePrimitiveParam } from "../core/pathSerializer"
import type { Client, ClientOptions, Config, RequestOptions } from "./types"
interface PathSerializer {
path: Record<string, unknown>;
url: string;
path: Record<string, unknown>
url: string
}
const PATH_PARAM_RE = /\{[^{}]+\}/g;
const PATH_PARAM_RE = /\{[^{}]+\}/g
type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited';
type MatrixStyle = 'label' | 'matrix' | 'simple';
type ArraySeparatorStyle = ArrayStyle | MatrixStyle;
type ArrayStyle = "form" | "spaceDelimited" | "pipeDelimited"
type MatrixStyle = "label" | "matrix" | "simple"
type ArraySeparatorStyle = ArrayStyle | MatrixStyle
const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
let url = _url;
const matches = _url.match(PATH_PARAM_RE);
let url = _url
const matches = _url.match(PATH_PARAM_RE)
if (matches) {
for (const match of matches) {
let explode = false;
let name = match.substring(1, match.length - 1);
let style: ArraySeparatorStyle = 'simple';
let explode = false
let name = match.substring(1, match.length - 1)
let style: ArraySeparatorStyle = "simple"
if (name.endsWith('*')) {
explode = true;
name = name.substring(0, name.length - 1);
if (name.endsWith("*")) {
explode = true
name = name.substring(0, name.length - 1)
}
if (name.startsWith('.')) {
name = name.substring(1);
style = 'label';
} else if (name.startsWith(';')) {
name = name.substring(1);
style = 'matrix';
if (name.startsWith(".")) {
name = name.substring(1)
style = "label"
} else if (name.startsWith(";")) {
name = name.substring(1)
style = "matrix"
}
const value = path[name];
const value = path[name]
if (value === undefined || value === null) {
continue;
continue
}
if (Array.isArray(value)) {
url = url.replace(
match,
serializeArrayParam({ explode, name, style, value }),
);
continue;
url = url.replace(match, serializeArrayParam({ explode, name, style, value }))
continue
}
if (typeof value === 'object') {
if (typeof value === "object") {
url = url.replace(
match,
serializeObjectParam({
@@ -68,43 +58,37 @@ const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
value: value as Record<string, unknown>,
valueOnly: true,
}),
);
continue;
)
continue
}
if (style === 'matrix') {
if (style === "matrix") {
url = url.replace(
match,
`;${serializePrimitiveParam({
name,
value: value as string,
})}`,
);
continue;
)
continue
}
const replaceValue = encodeURIComponent(
style === 'label' ? `.${value as string}` : (value as string),
);
url = url.replace(match, replaceValue);
const replaceValue = encodeURIComponent(style === "label" ? `.${value as string}` : (value as string))
url = url.replace(match, replaceValue)
}
}
return url;
};
return url
}
export const createQuerySerializer = <T = unknown>({
allowReserved,
array,
object,
}: QuerySerializerOptions = {}) => {
export const createQuerySerializer = <T = unknown>({ allowReserved, array, object }: QuerySerializerOptions = {}) => {
const querySerializer = (queryParams: T) => {
const search: string[] = [];
if (queryParams && typeof queryParams === 'object') {
const search: string[] = []
if (queryParams && typeof queryParams === "object") {
for (const name in queryParams) {
const value = queryParams[name];
const value = queryParams[name]
if (value === undefined || value === null) {
continue;
continue
}
if (Array.isArray(value)) {
@@ -112,129 +96,120 @@ export const createQuerySerializer = <T = unknown>({
allowReserved,
explode: true,
name,
style: 'form',
style: "form",
value,
...array,
});
if (serializedArray) search.push(serializedArray);
} else if (typeof value === 'object') {
})
if (serializedArray) search.push(serializedArray)
} else if (typeof value === "object") {
const serializedObject = serializeObjectParam({
allowReserved,
explode: true,
name,
style: 'deepObject',
style: "deepObject",
value: value as Record<string, unknown>,
...object,
});
if (serializedObject) search.push(serializedObject);
})
if (serializedObject) search.push(serializedObject)
} else {
const serializedPrimitive = serializePrimitiveParam({
allowReserved,
name,
value: value as string,
});
if (serializedPrimitive) search.push(serializedPrimitive);
})
if (serializedPrimitive) search.push(serializedPrimitive)
}
}
}
return search.join('&');
};
return querySerializer;
};
return search.join("&")
}
return querySerializer
}
/**
* Infers parseAs value from provided Content-Type header.
*/
export const getParseAs = (
contentType: string | null,
): Exclude<Config['parseAs'], 'auto'> => {
export const getParseAs = (contentType: string | null): Exclude<Config["parseAs"], "auto"> => {
if (!contentType) {
// If no Content-Type header is provided, the best we can do is return the raw response body,
// which is effectively the same as the 'stream' option.
return 'stream';
return "stream"
}
const cleanContent = contentType.split(';')[0]?.trim();
const cleanContent = contentType.split(";")[0]?.trim()
if (!cleanContent) {
return;
return
}
if (
cleanContent.startsWith('application/json') ||
cleanContent.endsWith('+json')
) {
return 'json';
if (cleanContent.startsWith("application/json") || cleanContent.endsWith("+json")) {
return "json"
}
if (cleanContent === 'multipart/form-data') {
return 'formData';
if (cleanContent === "multipart/form-data") {
return "formData"
}
if (
['application/', 'audio/', 'image/', 'video/'].some((type) =>
cleanContent.startsWith(type),
)
) {
return 'blob';
if (["application/", "audio/", "image/", "video/"].some((type) => cleanContent.startsWith(type))) {
return "blob"
}
if (cleanContent.startsWith('text/')) {
return 'text';
if (cleanContent.startsWith("text/")) {
return "text"
}
return;
};
return
}
export const setAuthParams = async ({
security,
...options
}: Pick<Required<RequestOptions>, 'security'> &
Pick<RequestOptions, 'auth' | 'query'> & {
headers: Headers;
}: Pick<Required<RequestOptions>, "security"> &
Pick<RequestOptions, "auth" | "query"> & {
headers: Headers
}) => {
for (const auth of security) {
const token = await getAuthToken(auth, options.auth);
const token = await getAuthToken(auth, options.auth)
if (!token) {
continue;
continue
}
const name = auth.name ?? 'Authorization';
const name = auth.name ?? "Authorization"
switch (auth.in) {
case 'query':
case "query":
if (!options.query) {
options.query = {};
options.query = {}
}
options.query[name] = token;
break;
case 'cookie':
options.headers.append('Cookie', `${name}=${token}`);
break;
case 'header':
options.query[name] = token
break
case "cookie":
options.headers.append("Cookie", `${name}=${token}`)
break
case "header":
default:
options.headers.set(name, token);
break;
options.headers.set(name, token)
break
}
return;
return
}
};
}
export const buildUrl: Client['buildUrl'] = (options) => {
export const buildUrl: Client["buildUrl"] = (options) => {
const url = getUrl({
baseUrl: options.baseUrl as string,
path: options.path,
query: options.query,
querySerializer:
typeof options.querySerializer === 'function'
typeof options.querySerializer === "function"
? options.querySerializer
: createQuerySerializer(options.querySerializer),
url: options.url,
});
return url;
};
})
return url
}
export const getUrl = ({
baseUrl,
@@ -243,144 +218,125 @@ export const getUrl = ({
querySerializer,
url: _url,
}: {
baseUrl?: string;
path?: Record<string, unknown>;
query?: Record<string, unknown>;
querySerializer: QuerySerializer;
url: string;
baseUrl?: string
path?: Record<string, unknown>
query?: Record<string, unknown>
querySerializer: QuerySerializer
url: string
}) => {
const pathUrl = _url.startsWith('/') ? _url : `/${_url}`;
let url = (baseUrl ?? '') + pathUrl;
const pathUrl = _url.startsWith("/") ? _url : `/${_url}`
let url = (baseUrl ?? "") + pathUrl
if (path) {
url = defaultPathSerializer({ path, url });
url = defaultPathSerializer({ path, url })
}
let search = query ? querySerializer(query) : '';
if (search.startsWith('?')) {
search = search.substring(1);
let search = query ? querySerializer(query) : ""
if (search.startsWith("?")) {
search = search.substring(1)
}
if (search) {
url += `?${search}`;
url += `?${search}`
}
return url;
};
return url
}
export const mergeConfigs = (a: Config, b: Config): Config => {
const config = { ...a, ...b };
if (config.baseUrl?.endsWith('/')) {
config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1);
const config = { ...a, ...b }
if (config.baseUrl?.endsWith("/")) {
config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1)
}
config.headers = mergeHeaders(a.headers, b.headers);
return config;
};
config.headers = mergeHeaders(a.headers, b.headers)
return config
}
export const mergeHeaders = (
...headers: Array<Required<Config>['headers'] | undefined>
): Headers => {
const mergedHeaders = new Headers();
export const mergeHeaders = (...headers: Array<Required<Config>["headers"] | undefined>): Headers => {
const mergedHeaders = new Headers()
for (const header of headers) {
if (!header || typeof header !== 'object') {
continue;
if (!header || typeof header !== "object") {
continue
}
const iterator =
header instanceof Headers ? header.entries() : Object.entries(header);
const iterator = header instanceof Headers ? header.entries() : Object.entries(header)
for (const [key, value] of iterator) {
if (value === null) {
mergedHeaders.delete(key);
mergedHeaders.delete(key)
} else if (Array.isArray(value)) {
for (const v of value) {
mergedHeaders.append(key, v as string);
mergedHeaders.append(key, v as string)
}
} else if (value !== undefined) {
// assume object headers are meant to be JSON stringified, i.e. their
// content value in OpenAPI specification is 'application/json'
mergedHeaders.set(
key,
typeof value === 'object' ? JSON.stringify(value) : (value as string),
);
mergedHeaders.set(key, typeof value === "object" ? JSON.stringify(value) : (value as string))
}
}
}
return mergedHeaders;
};
return mergedHeaders
}
type ErrInterceptor<Err, Res, Req, Options> = (
error: Err,
response: Res,
request: Req,
options: Options,
) => Err | Promise<Err>;
) => Err | Promise<Err>
type ReqInterceptor<Req, Options> = (
request: Req,
options: Options,
) => Req | Promise<Req>;
type ReqInterceptor<Req, Options> = (request: Req, options: Options) => Req | Promise<Req>
type ResInterceptor<Res, Req, Options> = (
response: Res,
request: Req,
options: Options,
) => Res | Promise<Res>;
type ResInterceptor<Res, Req, Options> = (response: Res, request: Req, options: Options) => Res | Promise<Res>
class Interceptors<Interceptor> {
_fns: (Interceptor | null)[];
_fns: (Interceptor | null)[]
constructor() {
this._fns = [];
this._fns = []
}
clear() {
this._fns = [];
this._fns = []
}
getInterceptorIndex(id: number | Interceptor): number {
if (typeof id === 'number') {
return this._fns[id] ? id : -1;
if (typeof id === "number") {
return this._fns[id] ? id : -1
} else {
return this._fns.indexOf(id);
return this._fns.indexOf(id)
}
}
exists(id: number | Interceptor) {
const index = this.getInterceptorIndex(id);
return !!this._fns[index];
const index = this.getInterceptorIndex(id)
return !!this._fns[index]
}
eject(id: number | Interceptor) {
const index = this.getInterceptorIndex(id);
const index = this.getInterceptorIndex(id)
if (this._fns[index]) {
this._fns[index] = null;
this._fns[index] = null
}
}
update(id: number | Interceptor, fn: Interceptor) {
const index = this.getInterceptorIndex(id);
const index = this.getInterceptorIndex(id)
if (this._fns[index]) {
this._fns[index] = fn;
return id;
this._fns[index] = fn
return id
} else {
return false;
return false
}
}
use(fn: Interceptor) {
this._fns = [...this._fns, fn];
return this._fns.length - 1;
this._fns = [...this._fns, fn]
return this._fns.length - 1
}
}
// `createInterceptors()` response, meant for external use as it does not
// expose internals
export interface Middleware<Req, Res, Err, Options> {
error: Pick<
Interceptors<ErrInterceptor<Err, Res, Req, Options>>,
'eject' | 'use'
>;
request: Pick<Interceptors<ReqInterceptor<Req, Options>>, 'eject' | 'use'>;
response: Pick<
Interceptors<ResInterceptor<Res, Req, Options>>,
'eject' | 'use'
>;
error: Pick<Interceptors<ErrInterceptor<Err, Res, Req, Options>>, "eject" | "use">
request: Pick<Interceptors<ReqInterceptor<Req, Options>>, "eject" | "use">
response: Pick<Interceptors<ResInterceptor<Res, Req, Options>>, "eject" | "use">
}
// do not add `Middleware` as return type so we can use _fns internally
@@ -388,30 +344,30 @@ export const createInterceptors = <Req, Res, Err, Options>() => ({
error: new Interceptors<ErrInterceptor<Err, Res, Req, Options>>(),
request: new Interceptors<ReqInterceptor<Req, Options>>(),
response: new Interceptors<ResInterceptor<Res, Req, Options>>(),
});
})
const defaultQuerySerializer = createQuerySerializer({
allowReserved: false,
array: {
explode: true,
style: 'form',
style: "form",
},
object: {
explode: true,
style: 'deepObject',
style: "deepObject",
},
});
})
const defaultHeaders = {
'Content-Type': 'application/json',
};
"Content-Type": "application/json",
}
export const createConfig = <T extends ClientOptions = ClientOptions>(
override: Config<Omit<ClientOptions, keyof T> & T> = {},
): Config<Omit<ClientOptions, keyof T> & T> => ({
...jsonBodySerializer,
headers: defaultHeaders,
parseAs: 'auto',
parseAs: "auto",
querySerializer: defaultQuerySerializer,
...override,
});
})
+13 -14
View File
@@ -1,4 +1,4 @@
export type AuthToken = string | undefined;
export type AuthToken = string | undefined
export interface Auth {
/**
@@ -6,35 +6,34 @@ export interface Auth {
*
* @default 'header'
*/
in?: 'header' | 'query' | 'cookie';
in?: "header" | "query" | "cookie"
/**
* Header or query parameter name.
*
* @default 'Authorization'
*/
name?: string;
scheme?: 'basic' | 'bearer';
type: 'apiKey' | 'http';
name?: string
scheme?: "basic" | "bearer"
type: "apiKey" | "http"
}
export const getAuthToken = async (
auth: Auth,
callback: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken,
): Promise<string | undefined> => {
const token =
typeof callback === 'function' ? await callback(auth) : callback;
const token = typeof callback === "function" ? await callback(auth) : callback
if (!token) {
return;
return
}
if (auth.scheme === 'bearer') {
return `Bearer ${token}`;
if (auth.scheme === "bearer") {
return `Bearer ${token}`
}
if (auth.scheme === 'basic') {
return `Basic ${btoa(token)}`;
if (auth.scheme === "basic") {
return `Basic ${btoa(token)}`
}
return token;
};
return token
}
+34 -52
View File
@@ -1,88 +1,70 @@
import type {
ArrayStyle,
ObjectStyle,
SerializerOptions,
} from './pathSerializer';
import type { ArrayStyle, ObjectStyle, SerializerOptions } from "./pathSerializer"
export type QuerySerializer = (query: Record<string, unknown>) => string;
export type QuerySerializer = (query: Record<string, unknown>) => string
export type BodySerializer = (body: any) => any;
export type BodySerializer = (body: any) => any
export interface QuerySerializerOptions {
allowReserved?: boolean;
array?: SerializerOptions<ArrayStyle>;
object?: SerializerOptions<ObjectStyle>;
allowReserved?: boolean
array?: SerializerOptions<ArrayStyle>
object?: SerializerOptions<ObjectStyle>
}
const serializeFormDataPair = (
data: FormData,
key: string,
value: unknown,
): void => {
if (typeof value === 'string' || value instanceof Blob) {
data.append(key, value);
const serializeFormDataPair = (data: FormData, key: string, value: unknown): void => {
if (typeof value === "string" || value instanceof Blob) {
data.append(key, value)
} else {
data.append(key, JSON.stringify(value));
data.append(key, JSON.stringify(value))
}
};
}
const serializeUrlSearchParamsPair = (
data: URLSearchParams,
key: string,
value: unknown,
): void => {
if (typeof value === 'string') {
data.append(key, value);
const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value: unknown): void => {
if (typeof value === "string") {
data.append(key, value)
} else {
data.append(key, JSON.stringify(value));
data.append(key, JSON.stringify(value))
}
};
}
export const formDataBodySerializer = {
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(
body: T,
): FormData => {
const data = new FormData();
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(body: T): FormData => {
const data = new FormData()
Object.entries(body).forEach(([key, value]) => {
if (value === undefined || value === null) {
return;
return
}
if (Array.isArray(value)) {
value.forEach((v) => serializeFormDataPair(data, key, v));
value.forEach((v) => serializeFormDataPair(data, key, v))
} else {
serializeFormDataPair(data, key, value);
serializeFormDataPair(data, key, value)
}
});
})
return data;
return data
},
};
}
export const jsonBodySerializer = {
bodySerializer: <T>(body: T): string =>
JSON.stringify(body, (_key, value) =>
typeof value === 'bigint' ? value.toString() : value,
),
};
JSON.stringify(body, (_key, value) => (typeof value === "bigint" ? value.toString() : value)),
}
export const urlSearchParamsBodySerializer = {
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(
body: T,
): string => {
const data = new URLSearchParams();
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(body: T): string => {
const data = new URLSearchParams()
Object.entries(body).forEach(([key, value]) => {
if (value === undefined || value === null) {
return;
return
}
if (Array.isArray(value)) {
value.forEach((v) => serializeUrlSearchParamsPair(data, key, v));
value.forEach((v) => serializeUrlSearchParamsPair(data, key, v))
} else {
serializeUrlSearchParamsPair(data, key, value);
serializeUrlSearchParamsPair(data, key, value)
}
});
})
return data.toString();
return data.toString()
},
};
}
+56 -65
View File
@@ -1,142 +1,133 @@
type Slot = 'body' | 'headers' | 'path' | 'query';
type Slot = "body" | "headers" | "path" | "query"
export type Field =
| {
in: Exclude<Slot, 'body'>;
in: Exclude<Slot, "body">
/**
* Field name. This is the name we want the user to see and use.
*/
key: string;
key: string
/**
* Field mapped name. This is the name we want to use in the request.
* If omitted, we use the same value as `key`.
*/
map?: string;
map?: string
}
| {
in: Extract<Slot, 'body'>;
in: Extract<Slot, "body">
/**
* Key isn't required for bodies.
*/
key?: string;
map?: string;
};
key?: string
map?: string
}
export interface Fields {
allowExtra?: Partial<Record<Slot, boolean>>;
args?: ReadonlyArray<Field>;
allowExtra?: Partial<Record<Slot, boolean>>
args?: ReadonlyArray<Field>
}
export type FieldsConfig = ReadonlyArray<Field | Fields>;
export type FieldsConfig = ReadonlyArray<Field | Fields>
const extraPrefixesMap: Record<string, Slot> = {
$body_: 'body',
$headers_: 'headers',
$path_: 'path',
$query_: 'query',
};
const extraPrefixes = Object.entries(extraPrefixesMap);
$body_: "body",
$headers_: "headers",
$path_: "path",
$query_: "query",
}
const extraPrefixes = Object.entries(extraPrefixesMap)
type KeyMap = Map<
string,
{
in: Slot;
map?: string;
in: Slot
map?: string
}
>;
>
const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => {
if (!map) {
map = new Map();
map = new Map()
}
for (const config of fields) {
if ('in' in config) {
if ("in" in config) {
if (config.key) {
map.set(config.key, {
in: config.in,
map: config.map,
});
})
}
} else if (config.args) {
buildKeyMap(config.args, map);
buildKeyMap(config.args, map)
}
}
return map;
};
return map
}
interface Params {
body: unknown;
headers: Record<string, unknown>;
path: Record<string, unknown>;
query: Record<string, unknown>;
body: unknown
headers: Record<string, unknown>
path: Record<string, unknown>
query: Record<string, unknown>
}
const stripEmptySlots = (params: Params) => {
for (const [slot, value] of Object.entries(params)) {
if (value && typeof value === 'object' && !Object.keys(value).length) {
delete params[slot as Slot];
if (value && typeof value === "object" && !Object.keys(value).length) {
delete params[slot as Slot]
}
}
};
}
export const buildClientParams = (
args: ReadonlyArray<unknown>,
fields: FieldsConfig,
) => {
export const buildClientParams = (args: ReadonlyArray<unknown>, fields: FieldsConfig) => {
const params: Params = {
body: {},
headers: {},
path: {},
query: {},
};
}
const map = buildKeyMap(fields);
const map = buildKeyMap(fields)
let config: FieldsConfig[number] | undefined;
let config: FieldsConfig[number] | undefined
for (const [index, arg] of args.entries()) {
if (fields[index]) {
config = fields[index];
config = fields[index]
}
if (!config) {
continue;
continue
}
if ('in' in config) {
if ("in" in config) {
if (config.key) {
const field = map.get(config.key)!;
const name = field.map || config.key;
(params[field.in] as Record<string, unknown>)[name] = arg;
const field = map.get(config.key)!
const name = field.map || config.key
;(params[field.in] as Record<string, unknown>)[name] = arg
} else {
params.body = arg;
params.body = arg
}
} else {
for (const [key, value] of Object.entries(arg ?? {})) {
const field = map.get(key);
const field = map.get(key)
if (field) {
const name = field.map || key;
(params[field.in] as Record<string, unknown>)[name] = value;
const name = field.map || key
;(params[field.in] as Record<string, unknown>)[name] = value
} else {
const extra = extraPrefixes.find(([prefix]) =>
key.startsWith(prefix),
);
const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix))
if (extra) {
const [prefix, slot] = extra;
(params[slot] as Record<string, unknown>)[
key.slice(prefix.length)
] = value;
const [prefix, slot] = extra
;(params[slot] as Record<string, unknown>)[key.slice(prefix.length)] = value
} else {
for (const [slot, allowed] of Object.entries(
config.allowExtra ?? {},
)) {
for (const [slot, allowed] of Object.entries(config.allowExtra ?? {})) {
if (allowed) {
(params[slot as Slot] as Record<string, unknown>)[key] = value;
break;
;(params[slot as Slot] as Record<string, unknown>)[key] = value
break
}
}
}
@@ -145,7 +136,7 @@ export const buildClientParams = (
}
}
stripEmptySlots(params);
stripEmptySlots(params)
return params;
};
return params
}
+80 -94
View File
@@ -1,68 +1,66 @@
interface SerializeOptions<T>
extends SerializePrimitiveOptions,
SerializerOptions<T> {}
interface SerializeOptions<T> extends SerializePrimitiveOptions, SerializerOptions<T> {}
interface SerializePrimitiveOptions {
allowReserved?: boolean;
name: string;
allowReserved?: boolean
name: string
}
export interface SerializerOptions<T> {
/**
* @default true
*/
explode: boolean;
style: T;
explode: boolean
style: T
}
export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited';
export type ArraySeparatorStyle = ArrayStyle | MatrixStyle;
type MatrixStyle = 'label' | 'matrix' | 'simple';
export type ObjectStyle = 'form' | 'deepObject';
type ObjectSeparatorStyle = ObjectStyle | MatrixStyle;
export type ArrayStyle = "form" | "spaceDelimited" | "pipeDelimited"
export type ArraySeparatorStyle = ArrayStyle | MatrixStyle
type MatrixStyle = "label" | "matrix" | "simple"
export type ObjectStyle = "form" | "deepObject"
type ObjectSeparatorStyle = ObjectStyle | MatrixStyle
interface SerializePrimitiveParam extends SerializePrimitiveOptions {
value: string;
value: string
}
export const separatorArrayExplode = (style: ArraySeparatorStyle) => {
switch (style) {
case 'label':
return '.';
case 'matrix':
return ';';
case 'simple':
return ',';
case "label":
return "."
case "matrix":
return ";"
case "simple":
return ","
default:
return '&';
return "&"
}
};
}
export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => {
switch (style) {
case 'form':
return ',';
case 'pipeDelimited':
return '|';
case 'spaceDelimited':
return '%20';
case "form":
return ","
case "pipeDelimited":
return "|"
case "spaceDelimited":
return "%20"
default:
return ',';
return ","
}
};
}
export const separatorObjectExplode = (style: ObjectSeparatorStyle) => {
switch (style) {
case 'label':
return '.';
case 'matrix':
return ';';
case 'simple':
return ',';
case "label":
return "."
case "matrix":
return ";"
case "simple":
return ","
default:
return '&';
return "&"
}
};
}
export const serializeArrayParam = ({
allowReserved,
@@ -71,60 +69,54 @@ export const serializeArrayParam = ({
style,
value,
}: SerializeOptions<ArraySeparatorStyle> & {
value: unknown[];
value: unknown[]
}) => {
if (!explode) {
const joinedValues = (
allowReserved ? value : value.map((v) => encodeURIComponent(v as string))
).join(separatorArrayNoExplode(style));
const joinedValues = (allowReserved ? value : value.map((v) => encodeURIComponent(v as string))).join(
separatorArrayNoExplode(style),
)
switch (style) {
case 'label':
return `.${joinedValues}`;
case 'matrix':
return `;${name}=${joinedValues}`;
case 'simple':
return joinedValues;
case "label":
return `.${joinedValues}`
case "matrix":
return `;${name}=${joinedValues}`
case "simple":
return joinedValues
default:
return `${name}=${joinedValues}`;
return `${name}=${joinedValues}`
}
}
const separator = separatorArrayExplode(style);
const separator = separatorArrayExplode(style)
const joinedValues = value
.map((v) => {
if (style === 'label' || style === 'simple') {
return allowReserved ? v : encodeURIComponent(v as string);
if (style === "label" || style === "simple") {
return allowReserved ? v : encodeURIComponent(v as string)
}
return serializePrimitiveParam({
allowReserved,
name,
value: v as string,
});
})
})
.join(separator);
return style === 'label' || style === 'matrix'
? separator + joinedValues
: joinedValues;
};
.join(separator)
return style === "label" || style === "matrix" ? separator + joinedValues : joinedValues
}
export const serializePrimitiveParam = ({
allowReserved,
name,
value,
}: SerializePrimitiveParam) => {
export const serializePrimitiveParam = ({ allowReserved, name, value }: SerializePrimitiveParam) => {
if (value === undefined || value === null) {
return '';
return ""
}
if (typeof value === 'object') {
if (typeof value === "object") {
throw new Error(
'Deeply-nested arrays/objects arent supported. Provide your own `querySerializer()` to handle these.',
);
"Deeply-nested arrays/objects arent supported. Provide your own `querySerializer()` to handle these.",
)
}
return `${name}=${allowReserved ? value : encodeURIComponent(value)}`;
};
return `${name}=${allowReserved ? value : encodeURIComponent(value)}`
}
export const serializeObjectParam = ({
allowReserved,
@@ -134,46 +126,40 @@ export const serializeObjectParam = ({
value,
valueOnly,
}: SerializeOptions<ObjectSeparatorStyle> & {
value: Record<string, unknown> | Date;
valueOnly?: boolean;
value: Record<string, unknown> | Date
valueOnly?: boolean
}) => {
if (value instanceof Date) {
return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`;
return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`
}
if (style !== 'deepObject' && !explode) {
let values: string[] = [];
if (style !== "deepObject" && !explode) {
let values: string[] = []
Object.entries(value).forEach(([key, v]) => {
values = [
...values,
key,
allowReserved ? (v as string) : encodeURIComponent(v as string),
];
});
const joinedValues = values.join(',');
values = [...values, key, allowReserved ? (v as string) : encodeURIComponent(v as string)]
})
const joinedValues = values.join(",")
switch (style) {
case 'form':
return `${name}=${joinedValues}`;
case 'label':
return `.${joinedValues}`;
case 'matrix':
return `;${name}=${joinedValues}`;
case "form":
return `${name}=${joinedValues}`
case "label":
return `.${joinedValues}`
case "matrix":
return `;${name}=${joinedValues}`
default:
return joinedValues;
return joinedValues
}
}
const separator = separatorObjectExplode(style);
const separator = separatorObjectExplode(style)
const joinedValues = Object.entries(value)
.map(([key, v]) =>
serializePrimitiveParam({
allowReserved,
name: style === 'deepObject' ? `${name}[${key}]` : key,
name: style === "deepObject" ? `${name}[${key}]` : key,
value: v as string,
}),
)
.join(separator);
return style === 'label' || style === 'matrix'
? separator + joinedValues
: joinedValues;
};
.join(separator)
return style === "label" || style === "matrix" ? separator + joinedValues : joinedValues
}
+28 -57
View File
@@ -1,32 +1,23 @@
import type { Auth, AuthToken } from './auth';
import type {
BodySerializer,
QuerySerializer,
QuerySerializerOptions,
} from './bodySerializer';
import type { Auth, AuthToken } from "./auth"
import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from "./bodySerializer"
export interface Client<
RequestFn = never,
Config = unknown,
MethodFn = never,
BuildUrlFn = never,
> {
export interface Client<RequestFn = never, Config = unknown, MethodFn = never, BuildUrlFn = never> {
/**
* Returns the final request URL.
*/
buildUrl: BuildUrlFn;
connect: MethodFn;
delete: MethodFn;
get: MethodFn;
getConfig: () => Config;
head: MethodFn;
options: MethodFn;
patch: MethodFn;
post: MethodFn;
put: MethodFn;
request: RequestFn;
setConfig: (config: Config) => Config;
trace: MethodFn;
buildUrl: BuildUrlFn
connect: MethodFn
delete: MethodFn
get: MethodFn
getConfig: () => Config
head: MethodFn
options: MethodFn
patch: MethodFn
post: MethodFn
put: MethodFn
request: RequestFn
setConfig: (config: Config) => Config
trace: MethodFn
}
export interface Config {
@@ -34,12 +25,12 @@ export interface Config {
* Auth token or a function returning auth token. The resolved value will be
* added to the request payload as defined by its `security` array.
*/
auth?: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken;
auth?: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken
/**
* A function for serializing request body parameter. By default,
* {@link JSON.stringify()} will be used.
*/
bodySerializer?: BodySerializer | null;
bodySerializer?: BodySerializer | null
/**
* An object containing any HTTP headers that you want to pre-populate your
* `Headers` object with.
@@ -47,32 +38,14 @@ export interface Config {
* {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more}
*/
headers?:
| RequestInit['headers']
| Record<
string,
| string
| number
| boolean
| (string | number | boolean)[]
| null
| undefined
| unknown
>;
| RequestInit["headers"]
| Record<string, string | number | boolean | (string | number | boolean)[] | null | undefined | unknown>
/**
* The request method.
*
* {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more}
*/
method?:
| 'CONNECT'
| 'DELETE'
| 'GET'
| 'HEAD'
| 'OPTIONS'
| 'PATCH'
| 'POST'
| 'PUT'
| 'TRACE';
method?: "CONNECT" | "DELETE" | "GET" | "HEAD" | "OPTIONS" | "PATCH" | "POST" | "PUT" | "TRACE"
/**
* A function for serializing request query parameters. By default, arrays
* will be exploded in form style, objects will be exploded in deepObject
@@ -83,24 +56,24 @@ export interface Config {
*
* {@link https://swagger.io/docs/specification/serialization/#query View examples}
*/
querySerializer?: QuerySerializer | QuerySerializerOptions;
querySerializer?: QuerySerializer | QuerySerializerOptions
/**
* A function validating request data. This is useful if you want to ensure
* the request conforms to the desired shape, so it can be safely sent to
* the server.
*/
requestValidator?: (data: unknown) => Promise<unknown>;
requestValidator?: (data: unknown) => Promise<unknown>
/**
* A function transforming response data before it's returned. This is useful
* for post-processing data, e.g. converting ISO strings into Date objects.
*/
responseTransformer?: (data: unknown) => Promise<unknown>;
responseTransformer?: (data: unknown) => Promise<unknown>
/**
* A function validating response data. This is useful if you want to ensure
* the response conforms to the desired shape, so it can be safely passed to
* the transformers and returned to the user.
*/
responseValidator?: (data: unknown) => Promise<unknown>;
responseValidator?: (data: unknown) => Promise<unknown>
}
type IsExactlyNeverOrNeverUndefined<T> = [T] extends [never]
@@ -109,10 +82,8 @@ type IsExactlyNeverOrNeverUndefined<T> = [T] extends [never]
? [undefined] extends [T]
? false
: true
: false;
: false
export type OmitNever<T extends Record<string, unknown>> = {
[K in keyof T as IsExactlyNeverOrNeverUndefined<T[K]> extends true
? never
: K]: T[K];
};
[K in keyof T as IsExactlyNeverOrNeverUndefined<T[K]> extends true ? never : K]: T[K]
}
+473 -396
View File
@@ -1,426 +1,503 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Options as ClientOptions, TDataShape, Client } from './client';
import type { EventSubscribeData, EventSubscribeResponses, AppGetData, AppGetResponses, AppInitData, AppInitResponses, ConfigGetData, ConfigGetResponses, SessionListData, SessionListResponses, SessionCreateData, SessionCreateResponses, SessionCreateErrors, SessionDeleteData, SessionDeleteResponses, SessionInitData, SessionInitResponses, SessionAbortData, SessionAbortResponses, SessionUnshareData, SessionUnshareResponses, SessionShareData, SessionShareResponses, SessionSummarizeData, SessionSummarizeResponses, SessionMessagesData, SessionMessagesResponses, SessionChatData, SessionChatResponses, SessionMessageData, SessionMessageResponses, SessionRevertData, SessionRevertResponses, SessionUnrevertData, SessionUnrevertResponses, PostSessionByIdPermissionsByPermissionIdData, PostSessionByIdPermissionsByPermissionIdResponses, ConfigProvidersData, ConfigProvidersResponses, FindTextData, FindTextResponses, FindFilesData, FindFilesResponses, FindSymbolsData, FindSymbolsResponses, FileReadData, FileReadResponses, FileStatusData, FileStatusResponses, AppLogData, AppLogResponses, AppModesData, AppModesResponses, TuiAppendPromptData, TuiAppendPromptResponses, TuiOpenHelpData, TuiOpenHelpResponses, TuiOpenSessionsData, TuiOpenSessionsResponses, TuiOpenThemesData, TuiOpenThemesResponses, TuiOpenModelsData, TuiOpenModelsResponses, TuiSubmitPromptData, TuiSubmitPromptResponses, TuiClearPromptData, TuiClearPromptResponses, TuiExecuteCommandData, TuiExecuteCommandResponses } from './types.gen';
import { client as _heyApiClient } from './client.gen';
import type { Options as ClientOptions, TDataShape, Client } from "./client"
import type {
EventSubscribeData,
EventSubscribeResponses,
AppGetData,
AppGetResponses,
AppInitData,
AppInitResponses,
ConfigGetData,
ConfigGetResponses,
SessionListData,
SessionListResponses,
SessionCreateData,
SessionCreateResponses,
SessionCreateErrors,
SessionDeleteData,
SessionDeleteResponses,
SessionInitData,
SessionInitResponses,
SessionAbortData,
SessionAbortResponses,
SessionUnshareData,
SessionUnshareResponses,
SessionShareData,
SessionShareResponses,
SessionSummarizeData,
SessionSummarizeResponses,
SessionMessagesData,
SessionMessagesResponses,
SessionChatData,
SessionChatResponses,
SessionMessageData,
SessionMessageResponses,
SessionRevertData,
SessionRevertResponses,
SessionUnrevertData,
SessionUnrevertResponses,
PostSessionByIdPermissionsByPermissionIdData,
PostSessionByIdPermissionsByPermissionIdResponses,
ConfigProvidersData,
ConfigProvidersResponses,
FindTextData,
FindTextResponses,
FindFilesData,
FindFilesResponses,
FindSymbolsData,
FindSymbolsResponses,
FileReadData,
FileReadResponses,
FileStatusData,
FileStatusResponses,
AppLogData,
AppLogResponses,
AppModesData,
AppModesResponses,
TuiAppendPromptData,
TuiAppendPromptResponses,
TuiOpenHelpData,
TuiOpenHelpResponses,
TuiOpenSessionsData,
TuiOpenSessionsResponses,
TuiOpenThemesData,
TuiOpenThemesResponses,
TuiOpenModelsData,
TuiOpenModelsResponses,
TuiSubmitPromptData,
TuiSubmitPromptResponses,
TuiClearPromptData,
TuiClearPromptResponses,
TuiExecuteCommandData,
TuiExecuteCommandResponses,
} from "./types.gen"
import { client as _heyApiClient } from "./client.gen"
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = ClientOptions<TData, ThrowOnError> & {
/**
* You can provide a client instance returned by `createClient()` instead of
* individual options. This might be also useful if you want to implement a
* custom client.
*/
client?: Client;
/**
* You can pass arbitrary values through the `meta` object. This can be
* used to access values that aren't defined as part of the SDK function.
*/
meta?: Record<string, unknown>;
};
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = ClientOptions<
TData,
ThrowOnError
> & {
/**
* You can provide a client instance returned by `createClient()` instead of
* individual options. This might be also useful if you want to implement a
* custom client.
*/
client?: Client
/**
* You can pass arbitrary values through the `meta` object. This can be
* used to access values that aren't defined as part of the SDK function.
*/
meta?: Record<string, unknown>
}
class _HeyApiClient {
protected _client: Client = _heyApiClient;
constructor(args?: {
client?: Client;
}) {
if (args?.client) {
this._client = args.client;
}
protected _client: Client = _heyApiClient
constructor(args?: { client?: Client }) {
if (args?.client) {
this._client = args.client
}
}
}
class Event extends _HeyApiClient {
/**
* Get events
*/
public subscribe<ThrowOnError extends boolean = false>(options?: Options<EventSubscribeData, ThrowOnError>) {
return (options?.client ?? this._client).get<EventSubscribeResponses, unknown, ThrowOnError>({
url: '/event',
...options
});
}
/**
* Get events
*/
public subscribe<ThrowOnError extends boolean = false>(options?: Options<EventSubscribeData, ThrowOnError>) {
return (options?.client ?? this._client).get<EventSubscribeResponses, unknown, ThrowOnError>({
url: "/event",
...options,
})
}
}
class App extends _HeyApiClient {
/**
* Get app info
*/
public get<ThrowOnError extends boolean = false>(options?: Options<AppGetData, ThrowOnError>) {
return (options?.client ?? this._client).get<AppGetResponses, unknown, ThrowOnError>({
url: '/app',
...options
});
}
/**
* Initialize the app
*/
public init<ThrowOnError extends boolean = false>(options?: Options<AppInitData, ThrowOnError>) {
return (options?.client ?? this._client).post<AppInitResponses, unknown, ThrowOnError>({
url: '/app/init',
...options
});
}
/**
* Write a log entry to the server logs
*/
public log<ThrowOnError extends boolean = false>(options?: Options<AppLogData, ThrowOnError>) {
return (options?.client ?? this._client).post<AppLogResponses, unknown, ThrowOnError>({
url: '/log',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
}
/**
* List all modes
*/
public modes<ThrowOnError extends boolean = false>(options?: Options<AppModesData, ThrowOnError>) {
return (options?.client ?? this._client).get<AppModesResponses, unknown, ThrowOnError>({
url: '/mode',
...options
});
}
/**
* Get app info
*/
public get<ThrowOnError extends boolean = false>(options?: Options<AppGetData, ThrowOnError>) {
return (options?.client ?? this._client).get<AppGetResponses, unknown, ThrowOnError>({
url: "/app",
...options,
})
}
/**
* Initialize the app
*/
public init<ThrowOnError extends boolean = false>(options?: Options<AppInitData, ThrowOnError>) {
return (options?.client ?? this._client).post<AppInitResponses, unknown, ThrowOnError>({
url: "/app/init",
...options,
})
}
/**
* Write a log entry to the server logs
*/
public log<ThrowOnError extends boolean = false>(options?: Options<AppLogData, ThrowOnError>) {
return (options?.client ?? this._client).post<AppLogResponses, unknown, ThrowOnError>({
url: "/log",
...options,
headers: {
"Content-Type": "application/json",
...options?.headers,
},
})
}
/**
* List all modes
*/
public modes<ThrowOnError extends boolean = false>(options?: Options<AppModesData, ThrowOnError>) {
return (options?.client ?? this._client).get<AppModesResponses, unknown, ThrowOnError>({
url: "/mode",
...options,
})
}
}
class Config extends _HeyApiClient {
/**
* Get config info
*/
public get<ThrowOnError extends boolean = false>(options?: Options<ConfigGetData, ThrowOnError>) {
return (options?.client ?? this._client).get<ConfigGetResponses, unknown, ThrowOnError>({
url: '/config',
...options
});
}
/**
* List all providers
*/
public providers<ThrowOnError extends boolean = false>(options?: Options<ConfigProvidersData, ThrowOnError>) {
return (options?.client ?? this._client).get<ConfigProvidersResponses, unknown, ThrowOnError>({
url: '/config/providers',
...options
});
}
/**
* Get config info
*/
public get<ThrowOnError extends boolean = false>(options?: Options<ConfigGetData, ThrowOnError>) {
return (options?.client ?? this._client).get<ConfigGetResponses, unknown, ThrowOnError>({
url: "/config",
...options,
})
}
/**
* List all providers
*/
public providers<ThrowOnError extends boolean = false>(options?: Options<ConfigProvidersData, ThrowOnError>) {
return (options?.client ?? this._client).get<ConfigProvidersResponses, unknown, ThrowOnError>({
url: "/config/providers",
...options,
})
}
}
class Session extends _HeyApiClient {
/**
* List all sessions
*/
public list<ThrowOnError extends boolean = false>(options?: Options<SessionListData, ThrowOnError>) {
return (options?.client ?? this._client).get<SessionListResponses, unknown, ThrowOnError>({
url: '/session',
...options
});
}
/**
* Create a new session
*/
public create<ThrowOnError extends boolean = false>(options?: Options<SessionCreateData, ThrowOnError>) {
return (options?.client ?? this._client).post<SessionCreateResponses, SessionCreateErrors, ThrowOnError>({
url: '/session',
...options
});
}
/**
* Delete a session and all its data
*/
public delete<ThrowOnError extends boolean = false>(options: Options<SessionDeleteData, ThrowOnError>) {
return (options.client ?? this._client).delete<SessionDeleteResponses, unknown, ThrowOnError>({
url: '/session/{id}',
...options
});
}
/**
* Analyze the app and create an AGENTS.md file
*/
public init<ThrowOnError extends boolean = false>(options: Options<SessionInitData, ThrowOnError>) {
return (options.client ?? this._client).post<SessionInitResponses, unknown, ThrowOnError>({
url: '/session/{id}/init',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
}
/**
* Abort a session
*/
public abort<ThrowOnError extends boolean = false>(options: Options<SessionAbortData, ThrowOnError>) {
return (options.client ?? this._client).post<SessionAbortResponses, unknown, ThrowOnError>({
url: '/session/{id}/abort',
...options
});
}
/**
* Unshare the session
*/
public unshare<ThrowOnError extends boolean = false>(options: Options<SessionUnshareData, ThrowOnError>) {
return (options.client ?? this._client).delete<SessionUnshareResponses, unknown, ThrowOnError>({
url: '/session/{id}/share',
...options
});
}
/**
* Share a session
*/
public share<ThrowOnError extends boolean = false>(options: Options<SessionShareData, ThrowOnError>) {
return (options.client ?? this._client).post<SessionShareResponses, unknown, ThrowOnError>({
url: '/session/{id}/share',
...options
});
}
/**
* Summarize the session
*/
public summarize<ThrowOnError extends boolean = false>(options: Options<SessionSummarizeData, ThrowOnError>) {
return (options.client ?? this._client).post<SessionSummarizeResponses, unknown, ThrowOnError>({
url: '/session/{id}/summarize',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
}
/**
* List messages for a session
*/
public messages<ThrowOnError extends boolean = false>(options: Options<SessionMessagesData, ThrowOnError>) {
return (options.client ?? this._client).get<SessionMessagesResponses, unknown, ThrowOnError>({
url: '/session/{id}/message',
...options
});
}
/**
* Create and send a new message to a session
*/
public chat<ThrowOnError extends boolean = false>(options: Options<SessionChatData, ThrowOnError>) {
return (options.client ?? this._client).post<SessionChatResponses, unknown, ThrowOnError>({
url: '/session/{id}/message',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
}
/**
* Get a message from a session
*/
public message<ThrowOnError extends boolean = false>(options: Options<SessionMessageData, ThrowOnError>) {
return (options.client ?? this._client).get<SessionMessageResponses, unknown, ThrowOnError>({
url: '/session/{id}/message/{messageID}',
...options
});
}
/**
* Revert a message
*/
public revert<ThrowOnError extends boolean = false>(options: Options<SessionRevertData, ThrowOnError>) {
return (options.client ?? this._client).post<SessionRevertResponses, unknown, ThrowOnError>({
url: '/session/{id}/revert',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
}
/**
* Restore all reverted messages
*/
public unrevert<ThrowOnError extends boolean = false>(options: Options<SessionUnrevertData, ThrowOnError>) {
return (options.client ?? this._client).post<SessionUnrevertResponses, unknown, ThrowOnError>({
url: '/session/{id}/unrevert',
...options
});
}
/**
* List all sessions
*/
public list<ThrowOnError extends boolean = false>(options?: Options<SessionListData, ThrowOnError>) {
return (options?.client ?? this._client).get<SessionListResponses, unknown, ThrowOnError>({
url: "/session",
...options,
})
}
/**
* Create a new session
*/
public create<ThrowOnError extends boolean = false>(options?: Options<SessionCreateData, ThrowOnError>) {
return (options?.client ?? this._client).post<SessionCreateResponses, SessionCreateErrors, ThrowOnError>({
url: "/session",
...options,
})
}
/**
* Delete a session and all its data
*/
public delete<ThrowOnError extends boolean = false>(options: Options<SessionDeleteData, ThrowOnError>) {
return (options.client ?? this._client).delete<SessionDeleteResponses, unknown, ThrowOnError>({
url: "/session/{id}",
...options,
})
}
/**
* Analyze the app and create an AGENTS.md file
*/
public init<ThrowOnError extends boolean = false>(options: Options<SessionInitData, ThrowOnError>) {
return (options.client ?? this._client).post<SessionInitResponses, unknown, ThrowOnError>({
url: "/session/{id}/init",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
})
}
/**
* Abort a session
*/
public abort<ThrowOnError extends boolean = false>(options: Options<SessionAbortData, ThrowOnError>) {
return (options.client ?? this._client).post<SessionAbortResponses, unknown, ThrowOnError>({
url: "/session/{id}/abort",
...options,
})
}
/**
* Unshare the session
*/
public unshare<ThrowOnError extends boolean = false>(options: Options<SessionUnshareData, ThrowOnError>) {
return (options.client ?? this._client).delete<SessionUnshareResponses, unknown, ThrowOnError>({
url: "/session/{id}/share",
...options,
})
}
/**
* Share a session
*/
public share<ThrowOnError extends boolean = false>(options: Options<SessionShareData, ThrowOnError>) {
return (options.client ?? this._client).post<SessionShareResponses, unknown, ThrowOnError>({
url: "/session/{id}/share",
...options,
})
}
/**
* Summarize the session
*/
public summarize<ThrowOnError extends boolean = false>(options: Options<SessionSummarizeData, ThrowOnError>) {
return (options.client ?? this._client).post<SessionSummarizeResponses, unknown, ThrowOnError>({
url: "/session/{id}/summarize",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
})
}
/**
* List messages for a session
*/
public messages<ThrowOnError extends boolean = false>(options: Options<SessionMessagesData, ThrowOnError>) {
return (options.client ?? this._client).get<SessionMessagesResponses, unknown, ThrowOnError>({
url: "/session/{id}/message",
...options,
})
}
/**
* Create and send a new message to a session
*/
public chat<ThrowOnError extends boolean = false>(options: Options<SessionChatData, ThrowOnError>) {
return (options.client ?? this._client).post<SessionChatResponses, unknown, ThrowOnError>({
url: "/session/{id}/message",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
})
}
/**
* Get a message from a session
*/
public message<ThrowOnError extends boolean = false>(options: Options<SessionMessageData, ThrowOnError>) {
return (options.client ?? this._client).get<SessionMessageResponses, unknown, ThrowOnError>({
url: "/session/{id}/message/{messageID}",
...options,
})
}
/**
* Revert a message
*/
public revert<ThrowOnError extends boolean = false>(options: Options<SessionRevertData, ThrowOnError>) {
return (options.client ?? this._client).post<SessionRevertResponses, unknown, ThrowOnError>({
url: "/session/{id}/revert",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
})
}
/**
* Restore all reverted messages
*/
public unrevert<ThrowOnError extends boolean = false>(options: Options<SessionUnrevertData, ThrowOnError>) {
return (options.client ?? this._client).post<SessionUnrevertResponses, unknown, ThrowOnError>({
url: "/session/{id}/unrevert",
...options,
})
}
}
class Find extends _HeyApiClient {
/**
* Find text in files
*/
public text<ThrowOnError extends boolean = false>(options: Options<FindTextData, ThrowOnError>) {
return (options.client ?? this._client).get<FindTextResponses, unknown, ThrowOnError>({
url: '/find',
...options
});
}
/**
* Find files
*/
public files<ThrowOnError extends boolean = false>(options: Options<FindFilesData, ThrowOnError>) {
return (options.client ?? this._client).get<FindFilesResponses, unknown, ThrowOnError>({
url: '/find/file',
...options
});
}
/**
* Find workspace symbols
*/
public symbols<ThrowOnError extends boolean = false>(options: Options<FindSymbolsData, ThrowOnError>) {
return (options.client ?? this._client).get<FindSymbolsResponses, unknown, ThrowOnError>({
url: '/find/symbol',
...options
});
}
/**
* Find text in files
*/
public text<ThrowOnError extends boolean = false>(options: Options<FindTextData, ThrowOnError>) {
return (options.client ?? this._client).get<FindTextResponses, unknown, ThrowOnError>({
url: "/find",
...options,
})
}
/**
* Find files
*/
public files<ThrowOnError extends boolean = false>(options: Options<FindFilesData, ThrowOnError>) {
return (options.client ?? this._client).get<FindFilesResponses, unknown, ThrowOnError>({
url: "/find/file",
...options,
})
}
/**
* Find workspace symbols
*/
public symbols<ThrowOnError extends boolean = false>(options: Options<FindSymbolsData, ThrowOnError>) {
return (options.client ?? this._client).get<FindSymbolsResponses, unknown, ThrowOnError>({
url: "/find/symbol",
...options,
})
}
}
class File extends _HeyApiClient {
/**
* Read a file
*/
public read<ThrowOnError extends boolean = false>(options: Options<FileReadData, ThrowOnError>) {
return (options.client ?? this._client).get<FileReadResponses, unknown, ThrowOnError>({
url: '/file',
...options
});
}
/**
* Get file status
*/
public status<ThrowOnError extends boolean = false>(options?: Options<FileStatusData, ThrowOnError>) {
return (options?.client ?? this._client).get<FileStatusResponses, unknown, ThrowOnError>({
url: '/file/status',
...options
});
}
/**
* Read a file
*/
public read<ThrowOnError extends boolean = false>(options: Options<FileReadData, ThrowOnError>) {
return (options.client ?? this._client).get<FileReadResponses, unknown, ThrowOnError>({
url: "/file",
...options,
})
}
/**
* Get file status
*/
public status<ThrowOnError extends boolean = false>(options?: Options<FileStatusData, ThrowOnError>) {
return (options?.client ?? this._client).get<FileStatusResponses, unknown, ThrowOnError>({
url: "/file/status",
...options,
})
}
}
class Tui extends _HeyApiClient {
/**
* Append prompt to the TUI
*/
public appendPrompt<ThrowOnError extends boolean = false>(options?: Options<TuiAppendPromptData, ThrowOnError>) {
return (options?.client ?? this._client).post<TuiAppendPromptResponses, unknown, ThrowOnError>({
url: '/tui/append-prompt',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
}
/**
* Open the help dialog
*/
public openHelp<ThrowOnError extends boolean = false>(options?: Options<TuiOpenHelpData, ThrowOnError>) {
return (options?.client ?? this._client).post<TuiOpenHelpResponses, unknown, ThrowOnError>({
url: '/tui/open-help',
...options
});
}
/**
* Open the session dialog
*/
public openSessions<ThrowOnError extends boolean = false>(options?: Options<TuiOpenSessionsData, ThrowOnError>) {
return (options?.client ?? this._client).post<TuiOpenSessionsResponses, unknown, ThrowOnError>({
url: '/tui/open-sessions',
...options
});
}
/**
* Open the theme dialog
*/
public openThemes<ThrowOnError extends boolean = false>(options?: Options<TuiOpenThemesData, ThrowOnError>) {
return (options?.client ?? this._client).post<TuiOpenThemesResponses, unknown, ThrowOnError>({
url: '/tui/open-themes',
...options
});
}
/**
* Open the model dialog
*/
public openModels<ThrowOnError extends boolean = false>(options?: Options<TuiOpenModelsData, ThrowOnError>) {
return (options?.client ?? this._client).post<TuiOpenModelsResponses, unknown, ThrowOnError>({
url: '/tui/open-models',
...options
});
}
/**
* Submit the prompt
*/
public submitPrompt<ThrowOnError extends boolean = false>(options?: Options<TuiSubmitPromptData, ThrowOnError>) {
return (options?.client ?? this._client).post<TuiSubmitPromptResponses, unknown, ThrowOnError>({
url: '/tui/submit-prompt',
...options
});
}
/**
* Clear the prompt
*/
public clearPrompt<ThrowOnError extends boolean = false>(options?: Options<TuiClearPromptData, ThrowOnError>) {
return (options?.client ?? this._client).post<TuiClearPromptResponses, unknown, ThrowOnError>({
url: '/tui/clear-prompt',
...options
});
}
/**
* Execute a TUI command (e.g. switch_mode)
*/
public executeCommand<ThrowOnError extends boolean = false>(options?: Options<TuiExecuteCommandData, ThrowOnError>) {
return (options?.client ?? this._client).post<TuiExecuteCommandResponses, unknown, ThrowOnError>({
url: '/tui/execute-command',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
}
/**
* Append prompt to the TUI
*/
public appendPrompt<ThrowOnError extends boolean = false>(options?: Options<TuiAppendPromptData, ThrowOnError>) {
return (options?.client ?? this._client).post<TuiAppendPromptResponses, unknown, ThrowOnError>({
url: "/tui/append-prompt",
...options,
headers: {
"Content-Type": "application/json",
...options?.headers,
},
})
}
/**
* Open the help dialog
*/
public openHelp<ThrowOnError extends boolean = false>(options?: Options<TuiOpenHelpData, ThrowOnError>) {
return (options?.client ?? this._client).post<TuiOpenHelpResponses, unknown, ThrowOnError>({
url: "/tui/open-help",
...options,
})
}
/**
* Open the session dialog
*/
public openSessions<ThrowOnError extends boolean = false>(options?: Options<TuiOpenSessionsData, ThrowOnError>) {
return (options?.client ?? this._client).post<TuiOpenSessionsResponses, unknown, ThrowOnError>({
url: "/tui/open-sessions",
...options,
})
}
/**
* Open the theme dialog
*/
public openThemes<ThrowOnError extends boolean = false>(options?: Options<TuiOpenThemesData, ThrowOnError>) {
return (options?.client ?? this._client).post<TuiOpenThemesResponses, unknown, ThrowOnError>({
url: "/tui/open-themes",
...options,
})
}
/**
* Open the model dialog
*/
public openModels<ThrowOnError extends boolean = false>(options?: Options<TuiOpenModelsData, ThrowOnError>) {
return (options?.client ?? this._client).post<TuiOpenModelsResponses, unknown, ThrowOnError>({
url: "/tui/open-models",
...options,
})
}
/**
* Submit the prompt
*/
public submitPrompt<ThrowOnError extends boolean = false>(options?: Options<TuiSubmitPromptData, ThrowOnError>) {
return (options?.client ?? this._client).post<TuiSubmitPromptResponses, unknown, ThrowOnError>({
url: "/tui/submit-prompt",
...options,
})
}
/**
* Clear the prompt
*/
public clearPrompt<ThrowOnError extends boolean = false>(options?: Options<TuiClearPromptData, ThrowOnError>) {
return (options?.client ?? this._client).post<TuiClearPromptResponses, unknown, ThrowOnError>({
url: "/tui/clear-prompt",
...options,
})
}
/**
* Execute a TUI command (e.g. switch_mode)
*/
public executeCommand<ThrowOnError extends boolean = false>(options?: Options<TuiExecuteCommandData, ThrowOnError>) {
return (options?.client ?? this._client).post<TuiExecuteCommandResponses, unknown, ThrowOnError>({
url: "/tui/execute-command",
...options,
headers: {
"Content-Type": "application/json",
...options?.headers,
},
})
}
}
export class OpencodeClient extends _HeyApiClient {
/**
* Respond to a permission request
*/
public postSessionByIdPermissionsByPermissionId<ThrowOnError extends boolean = false>(options: Options<PostSessionByIdPermissionsByPermissionIdData, ThrowOnError>) {
return (options.client ?? this._client).post<PostSessionByIdPermissionsByPermissionIdResponses, unknown, ThrowOnError>({
url: '/session/{id}/permissions/{permissionID}',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
}
event = new Event({ client: this._client });
app = new App({ client: this._client });
config = new Config({ client: this._client });
session = new Session({ client: this._client });
find = new Find({ client: this._client });
file = new File({ client: this._client });
tui = new Tui({ client: this._client });
}
/**
* Respond to a permission request
*/
public postSessionByIdPermissionsByPermissionId<ThrowOnError extends boolean = false>(
options: Options<PostSessionByIdPermissionsByPermissionIdData, ThrowOnError>,
) {
return (options.client ?? this._client).post<
PostSessionByIdPermissionsByPermissionIdResponses,
unknown,
ThrowOnError
>({
url: "/session/{id}/permissions/{permissionID}",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
})
}
event = new Event({ client: this._client })
app = new App({ client: this._client })
config = new Config({ client: this._client })
session = new Session({ client: this._client })
find = new Find({ client: this._client })
file = new File({ client: this._client })
tui = new Tui({ client: this._client })
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -1,6 +1,7 @@
import { createClient } from "./gen/client/client"
import { type Config } from "./gen/client/types"
import { OpencodeClient } from "./gen/sdk.gen"
export * from "./gen/types.gen"
export function createOpencodeClient(config?: Config) {
const client = createClient(config)
+9 -1
View File
@@ -5,7 +5,15 @@
"outDir": "dist",
"module": "preserve",
"declaration": true,
"moduleResolution": "bundler"
"moduleResolution": "bundler",
"lib": [
"es2022",
"dom",
"dom.iterable"
],
"customConditions": [
"development"
]
},
"include": [
"src"
+114 -57
View File
@@ -270,6 +270,50 @@ func (a *App) SwitchModeReverse() (*App, tea.Cmd) {
return a.cycleMode(false)
}
// findModelByFullID finds a model by its full ID in the format "provider/model"
func findModelByFullID(providers []opencode.Provider, fullModelID string) (*opencode.Provider, *opencode.Model) {
modelParts := strings.SplitN(fullModelID, "/", 2)
if len(modelParts) < 2 {
return nil, nil
}
providerID := modelParts[0]
modelID := modelParts[1]
return findModelByProviderAndModelID(providers, providerID, modelID)
}
// findModelByProviderAndModelID finds a model by provider ID and model ID
func findModelByProviderAndModelID(providers []opencode.Provider, providerID, modelID string) (*opencode.Provider, *opencode.Model) {
for _, provider := range providers {
if provider.ID != providerID {
continue
}
for _, model := range provider.Models {
if model.ID == modelID {
return &provider, &model
}
}
// Provider found but model not found
return nil, nil
}
// Provider not found
return nil, nil
}
// findProviderByID finds a provider by its ID
func findProviderByID(providers []opencode.Provider, providerID string) *opencode.Provider {
for _, provider := range providers {
if provider.ID == providerID {
return &provider
}
}
return nil
}
func (a *App) InitializeProvider() tea.Cmd {
providersResponse, err := a.Client.App.Providers(context.Background())
if err != nil {
@@ -278,29 +322,6 @@ func (a *App) InitializeProvider() tea.Cmd {
return nil
}
providers := providersResponse.Providers
var defaultProvider *opencode.Provider
var defaultModel *opencode.Model
var anthropic *opencode.Provider
for _, provider := range providers {
if provider.ID == "anthropic" {
anthropic = &provider
}
}
// default to anthropic if available
if anthropic != nil {
defaultProvider = anthropic
defaultModel = getDefaultModel(providersResponse, *anthropic)
}
for _, provider := range providers {
if defaultProvider == nil || defaultModel == nil {
defaultProvider = &provider
defaultModel = getDefaultModel(providersResponse, provider)
}
providers = append(providers, provider)
}
if len(providers) == 0 {
slog.Error("No providers configured")
return nil
@@ -314,50 +335,86 @@ func (a *App) InitializeProvider() tea.Cmd {
a.State.Model = model.ModelID
}
var currentProvider *opencode.Provider
var currentModel *opencode.Model
for _, provider := range providers {
if provider.ID == a.State.Provider {
currentProvider = &provider
var selectedProvider *opencode.Provider
var selectedModel *opencode.Model
for _, model := range provider.Models {
if model.ID == a.State.Model {
currentModel = &model
}
}
}
}
if currentProvider == nil || currentModel == nil {
currentProvider = defaultProvider
currentModel = defaultModel
}
var initialProvider *opencode.Provider
var initialModel *opencode.Model
// Priority 1: Command line --model flag (InitialModel)
if a.InitialModel != nil && *a.InitialModel != "" {
splits := strings.Split(*a.InitialModel, "/")
for _, provider := range providers {
if provider.ID == splits[0] {
initialProvider = &provider
for _, model := range provider.Models {
modelID := strings.Join(splits[1:], "/")
if model.ID == modelID {
initialModel = &model
}
}
if provider, model := findModelByFullID(providers, *a.InitialModel); provider != nil && model != nil {
selectedProvider = provider
selectedModel = model
slog.Debug("Selected model from command line", "provider", provider.ID, "model", model.ID)
} else {
slog.Debug("Command line model not found", "model", *a.InitialModel)
}
}
// Priority 2: Config file model setting
if selectedProvider == nil && a.Config.Model != "" {
if provider, model := findModelByFullID(providers, a.Config.Model); provider != nil && model != nil {
selectedProvider = provider
selectedModel = model
slog.Debug("Selected model from config", "provider", provider.ID, "model", model.ID)
} else {
slog.Debug("Config model not found", "model", a.Config.Model)
}
}
// Priority 3: Recent model usage (most recently used model)
if selectedProvider == nil && len(a.State.RecentlyUsedModels) > 0 {
recentUsage := a.State.RecentlyUsedModels[0] // Most recent is first
if provider, model := findModelByProviderAndModelID(providers, recentUsage.ProviderID, recentUsage.ModelID); provider != nil && model != nil {
selectedProvider = provider
selectedModel = model
slog.Debug("Selected model from recent usage", "provider", provider.ID, "model", model.ID)
} else {
slog.Debug("Recent model not found", "provider", recentUsage.ProviderID, "model", recentUsage.ModelID)
}
}
// Priority 4: State-based model (backwards compatibility)
if selectedProvider == nil && a.State.Provider != "" && a.State.Model != "" {
if provider, model := findModelByProviderAndModelID(providers, a.State.Provider, a.State.Model); provider != nil && model != nil {
selectedProvider = provider
selectedModel = model
slog.Debug("Selected model from state", "provider", provider.ID, "model", model.ID)
} else {
slog.Debug("State model not found", "provider", a.State.Provider, "model", a.State.Model)
}
}
// Priority 5: Internal priority fallback (Anthropic preferred, then first available)
if selectedProvider == nil {
// Try Anthropic first as internal priority
if provider := findProviderByID(providers, "anthropic"); provider != nil {
if model := getDefaultModel(providersResponse, *provider); model != nil {
selectedProvider = provider
selectedModel = model
slog.Debug("Selected model from internal priority (Anthropic)", "provider", provider.ID, "model", model.ID)
}
}
// If Anthropic not available, use first available provider
if selectedProvider == nil && len(providers) > 0 {
provider := &providers[0]
if model := getDefaultModel(providersResponse, *provider); model != nil {
selectedProvider = provider
selectedModel = model
slog.Debug("Selected model from fallback (first available)", "provider", provider.ID, "model", model.ID)
}
}
}
if initialProvider != nil && initialModel != nil {
currentProvider = initialProvider
currentModel = initialModel
// Final safety check
if selectedProvider == nil || selectedModel == nil {
slog.Error("Failed to select any model")
return nil
}
var cmds []tea.Cmd
cmds = append(cmds, util.CmdHandler(ModelSelectedMsg{
Provider: *currentProvider,
Model: *currentModel,
Provider: *selectedProvider,
Model: *selectedModel,
}))
if a.InitialPrompt != nil && *a.InitialPrompt != "" {
cmds = append(cmds, util.CmdHandler(SendPrompt{Text: *a.InitialPrompt}))
+228
View File
@@ -0,0 +1,228 @@
package app
import (
"testing"
"github.com/sst/opencode-sdk-go"
)
// TestFindModelByFullID tests the findModelByFullID function
func TestFindModelByFullID(t *testing.T) {
// Create test providers with models
providers := []opencode.Provider{
{
ID: "anthropic",
Models: map[string]opencode.Model{
"claude-3-opus-20240229": {ID: "claude-3-opus-20240229"},
"claude-3-sonnet-20240229": {ID: "claude-3-sonnet-20240229"},
},
},
{
ID: "openai",
Models: map[string]opencode.Model{
"gpt-4": {ID: "gpt-4"},
"gpt-3.5-turbo": {ID: "gpt-3.5-turbo"},
},
},
}
tests := []struct {
name string
fullModelID string
expectedFound bool
expectedProviderID string
expectedModelID string
}{
{
name: "valid full model ID",
fullModelID: "anthropic/claude-3-opus-20240229",
expectedFound: true,
expectedProviderID: "anthropic",
expectedModelID: "claude-3-opus-20240229",
},
{
name: "valid full model ID with slash in model name",
fullModelID: "openai/gpt-3.5-turbo",
expectedFound: true,
expectedProviderID: "openai",
expectedModelID: "gpt-3.5-turbo",
},
{
name: "invalid format - missing slash",
fullModelID: "anthropic",
expectedFound: false,
},
{
name: "invalid format - empty string",
fullModelID: "",
expectedFound: false,
},
{
name: "provider not found",
fullModelID: "nonexistent/model",
expectedFound: false,
},
{
name: "model not found",
fullModelID: "anthropic/nonexistent-model",
expectedFound: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
provider, model := findModelByFullID(providers, tt.fullModelID)
if tt.expectedFound {
if provider == nil || model == nil {
t.Errorf("Expected to find provider/model, but got nil")
return
}
if provider.ID != tt.expectedProviderID {
t.Errorf("Expected provider ID %s, got %s", tt.expectedProviderID, provider.ID)
}
if model.ID != tt.expectedModelID {
t.Errorf("Expected model ID %s, got %s", tt.expectedModelID, model.ID)
}
} else {
if provider != nil || model != nil {
t.Errorf("Expected not to find provider/model, but got provider: %v, model: %v", provider, model)
}
}
})
}
}
// TestFindModelByProviderAndModelID tests the findModelByProviderAndModelID function
func TestFindModelByProviderAndModelID(t *testing.T) {
// Create test providers with models
providers := []opencode.Provider{
{
ID: "anthropic",
Models: map[string]opencode.Model{
"claude-3-opus-20240229": {ID: "claude-3-opus-20240229"},
"claude-3-sonnet-20240229": {ID: "claude-3-sonnet-20240229"},
},
},
{
ID: "openai",
Models: map[string]opencode.Model{
"gpt-4": {ID: "gpt-4"},
"gpt-3.5-turbo": {ID: "gpt-3.5-turbo"},
},
},
}
tests := []struct {
name string
providerID string
modelID string
expectedFound bool
expectedProviderID string
expectedModelID string
}{
{
name: "valid provider and model",
providerID: "anthropic",
modelID: "claude-3-opus-20240229",
expectedFound: true,
expectedProviderID: "anthropic",
expectedModelID: "claude-3-opus-20240229",
},
{
name: "provider not found",
providerID: "nonexistent",
modelID: "claude-3-opus-20240229",
expectedFound: false,
},
{
name: "model not found",
providerID: "anthropic",
modelID: "nonexistent-model",
expectedFound: false,
},
{
name: "both provider and model not found",
providerID: "nonexistent",
modelID: "nonexistent-model",
expectedFound: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
provider, model := findModelByProviderAndModelID(providers, tt.providerID, tt.modelID)
if tt.expectedFound {
if provider == nil || model == nil {
t.Errorf("Expected to find provider/model, but got nil")
return
}
if provider.ID != tt.expectedProviderID {
t.Errorf("Expected provider ID %s, got %s", tt.expectedProviderID, provider.ID)
}
if model.ID != tt.expectedModelID {
t.Errorf("Expected model ID %s, got %s", tt.expectedModelID, model.ID)
}
} else {
if provider != nil || model != nil {
t.Errorf("Expected not to find provider/model, but got provider: %v, model: %v", provider, model)
}
}
})
}
}
// TestFindProviderByID tests the findProviderByID function
func TestFindProviderByID(t *testing.T) {
// Create test providers
providers := []opencode.Provider{
{ID: "anthropic"},
{ID: "openai"},
{ID: "google"},
}
tests := []struct {
name string
providerID string
expectedFound bool
expectedProviderID string
}{
{
name: "provider found",
providerID: "anthropic",
expectedFound: true,
expectedProviderID: "anthropic",
},
{
name: "provider not found",
providerID: "nonexistent",
expectedFound: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
provider := findProviderByID(providers, tt.providerID)
if tt.expectedFound {
if provider == nil {
t.Errorf("Expected to find provider, but got nil")
return
}
if provider.ID != tt.expectedProviderID {
t.Errorf("Expected provider ID %s, got %s", tt.expectedProviderID, provider.ID)
}
} else {
if provider != nil {
t.Errorf("Expected not to find provider, but got %v", provider)
}
}
})
}
}
+8
View File
@@ -120,5 +120,13 @@ func LoadState(filePath string) (*State, error) {
}
return nil, fmt.Errorf("failed to decode TOML from file %s: %w", filePath, err)
}
// Restore attachment sources types that were deserialized as map[string]any
for _, prompt := range state.MessageHistory {
for _, att := range prompt.Attachments {
att.RestoreSourceType()
}
}
return &state, nil
}
@@ -75,3 +75,80 @@ func (a *Attachment) GetSymbolSource() (*SymbolSource, bool) {
ss, ok := a.Source.(*SymbolSource)
return ss, ok
}
// FromMap creates a TextSource from a map[string]any
func (ts *TextSource) FromMap(sourceMap map[string]any) {
if value, ok := sourceMap["value"].(string); ok {
ts.Value = value
}
}
// FromMap creates a FileSource from a map[string]any
func (fs *FileSource) FromMap(sourceMap map[string]any) {
if path, ok := sourceMap["path"].(string); ok {
fs.Path = path
}
if mime, ok := sourceMap["mime"].(string); ok {
fs.Mime = mime
}
if data, ok := sourceMap["data"].([]byte); ok {
fs.Data = data
}
}
// FromMap creates a SymbolSource from a map[string]any
func (ss *SymbolSource) FromMap(sourceMap map[string]any) {
if path, ok := sourceMap["path"].(string); ok {
ss.Path = path
}
if name, ok := sourceMap["name"].(string); ok {
ss.Name = name
}
if kind, ok := sourceMap["kind"].(int); ok {
ss.Kind = kind
}
if rangeMap, ok := sourceMap["range"].(map[string]any); ok {
ss.Range = SymbolRange{}
if startMap, ok := rangeMap["start"].(map[string]any); ok {
if line, ok := startMap["line"].(int); ok {
ss.Range.Start.Line = line
}
if char, ok := startMap["char"].(int); ok {
ss.Range.Start.Char = char
}
}
if endMap, ok := rangeMap["end"].(map[string]any); ok {
if line, ok := endMap["line"].(int); ok {
ss.Range.End.Line = line
}
if char, ok := endMap["char"].(int); ok {
ss.Range.End.Char = char
}
}
}
}
// RestoreSourceType converts a map[string]any source back to the proper type
func (a *Attachment) RestoreSourceType() {
if a.Source == nil {
return
}
// Check if Source is a map[string]any
if sourceMap, ok := a.Source.(map[string]any); ok {
switch a.Type {
case "text":
ts := &TextSource{}
ts.FromMap(sourceMap)
a.Source = ts
case "file":
fs := &FileSource{}
fs.FromMap(sourceMap)
a.Source = fs
case "symbol":
ss := &SymbolSource{}
ss.FromMap(sourceMap)
a.Source = ss
}
}
}
+6 -4
View File
@@ -2,6 +2,7 @@ package commands
import (
"encoding/json"
"log/slog"
"slices"
"strings"
@@ -155,6 +156,9 @@ func (k Command) Matches(msg tea.KeyPressMsg, leader bool) bool {
func parseBindings(bindings ...string) []Keybinding {
var parsedBindings []Keybinding
for _, binding := range bindings {
if binding == "none" {
continue
}
for p := range strings.SplitSeq(binding, ",") {
requireLeader := strings.HasPrefix(p, "<leader>")
keybinding := strings.ReplaceAll(p, "<leader>", "")
@@ -219,7 +223,6 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
{
Name: SessionUnshareCommand,
Description: "unshare session",
Keybindings: parseBindings("<leader>u"),
Trigger: []string{"unshare"},
},
{
@@ -375,15 +378,14 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
// Remove share/unshare commands if sharing is disabled
if config.Share == opencode.ConfigShareDisabled &&
(command.Name == SessionShareCommand || command.Name == SessionUnshareCommand) {
slog.Info("Removing share/unshare commands")
continue
}
if keybind, ok := keybinds[string(command.Name)]; ok && keybind != "" {
if keybind == "none" {
continue
}
command.Keybindings = parseBindings(keybind)
}
registry[command.Name] = command
}
slog.Info("Loaded commands", "commands", registry)
return registry
}
@@ -217,15 +217,27 @@ func renderText(
base := styles.NewStyle().Foreground(t.Text()).Background(backgroundColor)
text = ansi.WordwrapWc(text, width-6, " -")
// Build list of attachment filenames for highlighting
var result strings.Builder
lastEnd := int64(0)
// Apply highlighting to filenames and base style to rest of text
for _, filePart := range fileParts {
atFilename := "@" + filePart.Filename
// Find and highlight complete @filename references
highlightStyle := base.Foreground(t.Secondary())
text = strings.ReplaceAll(text, atFilename, highlightStyle.Render(atFilename))
highlight := base.Foreground(t.Secondary())
start, end := filePart.Source.Text.Start, filePart.Source.Text.End
if start > lastEnd {
result.WriteString(base.Render(text[lastEnd:start]))
}
result.WriteString(highlight.Render(text[start:end]))
lastEnd = end
}
content = base.Width(width - 6).Render(text)
if lastEnd < int64(len(text)) {
result.WriteString(base.Render(text[lastEnd:]))
}
content = base.Width(width - 6).Render(result.String())
}
timestamp := ts.
@@ -438,6 +450,10 @@ func renderToolDetails(
if stdout != nil {
body += ansi.Strip(fmt.Sprintf("%s", stdout))
}
stderr := metadata["stderr"]
if stderr != nil {
body += ansi.Strip(fmt.Sprintf("%s", stderr))
}
body += "```"
body = util.ToMarkdown(body, width, backgroundColor)
case "webfetch":
+1
View File
@@ -84,6 +84,7 @@ export default defineConfig({
"docs/permissions",
"docs/mcp-servers",
"docs/formatters",
"docs/lsp",
],
},
],
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode/web",
"type": "module",
"version": "0.0.1",
"version": "0.3.113",
"scripts": {
"dev": "astro dev",
"dev:remote": "sst shell --stage=dev --target=Web astro dev",
+5
View File
@@ -0,0 +1,5 @@
User-agent: *
Allow: /
# Disallow shared content pages
Disallow: /s/
+20 -4
View File
@@ -41,10 +41,26 @@ You can also install it with the following:
- **Using Node.js**
<Tabs>
<TabItem label="npm">```bash npm install -g opencode-ai ```</TabItem>
<TabItem label="Bun">```bash bun install -g opencode-ai ```</TabItem>
<TabItem label="pnpm">```bash pnpm install -g opencode-ai ```</TabItem>
<TabItem label="Yarn">```bash yarn global add opencode-ai ```</TabItem>
<TabItem label="npm">
```bash
npm install -g opencode-ai
```
</TabItem>
<TabItem label="Bun">
```bash
bun install -g opencode-ai
```
</TabItem>
<TabItem label="pnpm">
```bash
pnpm install -g opencode-ai
```
</TabItem>
<TabItem label="Yarn">
```bash
yarn global add opencode-ai
```
</TabItem>
</Tabs>
- **Using Homebrew on macOS**
@@ -1,34 +0,0 @@
---
title: LSP servers
---
opencode integrates with _Language Server Protocol_, or LSP to improve how the LLM interacts with your codebase.
LSP servers for different languages give the LLM:
- **Diagnostics**: These include things like errors and lint warnings. So the LLM can generate code that has fewer mistakes without having to run the code.
- **Quick actions**: The LSP can allow the LLM to better navigate the codebase through features like _go-to-definition_ and _find references_.
## Auto-detection
By default, opencode will **automatically detect** the languages used in your project and add the right LSP servers.
## Manual configuration
You can also manually configure LSP servers by adding them under the `lsp` section in your opencode config.
```json title="opencode.json"
{
"lsp": {
"go": {
"disabled": false,
"command": "gopls"
},
"typescript": {
"disabled": false,
"command": "typescript-language-server",
"args": ["--stdio"]
}
}
}
```
@@ -0,0 +1,79 @@
---
title: LSP Servers
description: Language Server Protocol integration with opencode
---
opencode integrates with Language Server Protocol (LSP) to enhance how the LLM interacts with your codebase. LSP servers provide intelligent code analysis and editing capabilities for different programming languages.
## Built-in LSP Servers
opencode comes with several built-in LSP servers for popular languages:
| LSP Server | Languages/Extensions | Requirements |
| ---------- | -------------------------------------------- | ----------------------------------- |
| typescript | .ts, .tsx, .js, .jsx, .mjs, .cjs, .mts, .cts | `typescript` dependency in project |
| gopls | .go | `go` command available |
| ruby-lsp | .rb, .rake, .gemspec, .ru | `ruby` and `gem` commands available |
| pyright | .py, .pyi | `pyright` dependency installed |
| elixir-ls | .ex, .exs | `elixir` command available |
| zls | .zig, .zon | `zig` command available |
| csharp | .cs | `.NET SDK` installed |
LSP servers are automatically enabled when their requirements are met in your project environment.
## Configuration
You can customize LSP servers through the `lsp` section in your `opencode.json` configuration file.
### Disabling LSP Servers
To disable a specific LSP server, set its configuration to `{ "disabled": true }`:
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"lsp": {
"typescript": {
"disabled": true
}
}
}
```
### Custom LSP Servers
You can add custom LSP servers by specifying the command and file extensions:
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"lsp": {
"custom-lsp": {
"command": ["custom-lsp-server", "--stdio"],
"extensions": [".custom"]
}
}
}
```
### Configuration Options
Each LSP server configuration supports these properties:
| Property | Type | Description |
| ---------------- | -------- | ------------------------------------------------- |
| `disabled` | boolean | Set to `true` to disable the LSP server |
| `command` | string[] | The command to start the LSP server |
| `extensions` | string[] | File extensions this LSP server should handle |
| `env` | object | Environment variables to set when starting server |
| `initialization` | object | Initialization options to send to the LSP server |
## How It Works
When opencode opens a file, it:
1. Checks the file extension against all enabled LSP servers
2. Starts the appropriate LSP server if not already running
3. Provides intelligent code analysis and editing capabilities
This integration allows the LLM to better understand your codebase through features like diagnostics, go-to-definition, and find-references.
@@ -66,9 +66,11 @@ If you've configured a [custom provider](/docs/providers#custom), the `provider_
## Loading models
When opencode starts up, it checks for the following:
When opencode starts up, it checks for models in the following priority order:
1. The model list in the opencode config.
1. The `--model` or `-m` command line flag. The format is the same as in the config file: `provider_id/model_id`.
2. The model list in the opencode config.
```json title="opencode.json"
{
@@ -79,6 +81,6 @@ When opencode starts up, it checks for the following:
The format here is `provider/model`.
2. The last used model.
3. The last used model.
3. The first model using an internal priority.
4. The first model using an internal priority.
@@ -72,3 +72,19 @@ For example.
}
}
```
- **Use wildcard patterns to restrict specific commands**
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"permission": {
"bash": {
"git push": "ask",
"*": "allow"
}
}
}
```
This configuration allows all commands by default (`"*": "allow"`) but requires approval for `git push` commands.
@@ -327,6 +327,42 @@ Or if you already have an API key, you can select **Manually enter API Key** and
---
### Cerebras
1. Head over to the [Cerebras console](https://inference.cerebras.ai/), create an account, and generate an API key.
2. Run `opencode auth login` and select **Cerebras**.
```bash
$ opencode auth login
┌ Add credential
◆ Select provider
│ ● Cerebras
│ ...
```
3. Enter your Cerebras API key.
```bash
$ opencode auth login
┌ Add credential
◇ Select provider
│ Cerebras
◇ Enter your API key
│ _
```
4. Run the `/models` command to select a model like _Qwen 3 Coder 480B_.
---
### DeepSeek
1. Head over to the [DeepSeek console](https://platform.deepseek.com/), create an account, and click **Create new API key**.
@@ -552,7 +588,7 @@ To use Kimi K2 from Moonshot AI:
5. Configure Moonshot in your opencode config.
```json title="opencode.json" "\"moonshot\"" {5-15}
```json title="opencode.json" ""moonshot"" {5-15}
{
"$schema": "https://opencode.ai/config.json",
"provider": {
@@ -721,87 +757,6 @@ https://platform.openai.com/api-keys
}
```
{ /*
---
TODO: Test a model that actually works, currently getting errors form the API
for Qwen non-thinking models.
### Cerebras
Cerebras offers fast inference with generous free tiers and competitive pricing.
1. Head over to the [Cerebras console](https://inference.cerebras.ai/), create an account, and generate an API key.
2. Run `opencode auth login` and select **Other**.
```bash
$ opencode auth login
┌ Add credential
◆ Select provider
│ ...
│ ● Other
```
3. Enter `cerebras` as the provider ID.
```bash
$ opencode auth login
┌ Add credential
◇ Select provider
│ Other
◇ Enter provider id
│ cerebras
```
4. Enter your Cerebras API key.
```bash
$ opencode auth login
┌ Add credential
◇ Enter your API key
│ csk-...
```
5. Configure Cerebras in your opencode config.
```json title="opencode.json" "cerebras" {5-19}
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"cerebras": {
"npm": "@ai-sdk/cerebras",
"name": "Cerebras",
"options": {
"baseURL": "https://api.cerebras.ai/v1"
},
"models": {
"qwen-3-235b-a22b": {
"name": "Qwen-3-235b-a22b"
},
"llama-3.3-70b": {
"name": "Llama-3.3-70b"
}
}
}
}
}
```
6. Run the `/models` command to select a Cerebras model.
*/ }
---
### Together AI
@@ -840,6 +795,42 @@ Cerebras offers fast inference with generous free tiers and competitive pricing.
---
### Zhipu AI
1. Head over to the [Zhipu API console](https://z.ai/manage-apikey/apikey-list), create an account, and click **Create a new API key**.
2. Run `opencode auth login` and select **Zhipu AI**.
```bash
$ opencode auth login
┌ Add credential
◆ Select provider
│ ● Zhipu AI
│ ...
```
3. Enter your Zhipu AI API key.
```bash
$ opencode auth login
┌ Add credential
◇ Select provider
│ Zhipu AI
◇ Enter your API key
│ _
```
4. Run the `/models` command to select a model like _GLM-4.5_.
---
## Troubleshooting
If you are having trouble with configuring a provider, check the following:
+7
View File
@@ -67,6 +67,13 @@ const ogImage = `${config.socialCard}/opencode-share/${encodedTitle}.png?model=$
content: "opencode - The AI coding agent built for the terminal.",
},
},
{
tag: "meta",
attrs: {
name: "robots",
content: "noindex, nofollow, noarchive, nosnippet",
}
},
{
tag: "meta",
attrs: {
+26 -3
View File
@@ -11,18 +11,41 @@ if (!version) {
}
process.env["OPENCODE_VERSION"] = version
const pkgjsons = await Array.fromAsync(
new Bun.Glob("**/package.json").scan({
absolute: true,
}),
)
const tree = await $`git add . && git write-tree`.text().then((x) => x.trim())
for await (const file of new Bun.Glob("**/package.json").scan({
absolute: true,
})) {
let pkg = await Bun.file(file).text()
pkg = pkg.replaceAll(/"version": "[^"]+"/g, `"version": "${version}"`)
await Bun.file(file).write(pkg)
}
await import(`../packages/opencode/script/publish.ts`)
await import(`../packages/sdk/js/script/publish.ts`)
await import(`../packages/plugin/script/publish.ts`)
// await import(`../packages/sdk/stainless/generate.ts`)
if (!snapshot) {
await $`git commit -am "release: v${version}"`
await $`git tag v${version}`
await $`git push origin HEAD --tags`
await $`git push origin HEAD --tags --no-verify`
}
if (snapshot) {
await $`git checkout -b snapshot-${version}`
await $`git commit --allow-empty -m "Snapshot release v${version}"`
await $`git tag v${version}`
await $`git push origin v${version}`
await $`git reset --soft HEAD~1`
await $`git push origin v${version} --no-verify`
await $`git checkout dev`
await $`git branch -D snapshot-${version}`
for await (const file of new Bun.Glob("**/package.json").scan({
absolute: true,
})) {
$`await git checkout ${tree} ${file}`
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "0.0.0",
"version": "0.3.113",
"publisher": "sst-dev",
"repository": {
"type": "git",
-15
View File
@@ -1,15 +0,0 @@
import * as assert from 'assert';
// You can import and use all API from the 'vscode' module
// as well as import your extension to test it
import * as vscode from 'vscode';
// import * as myExtension from '../../extension';
suite('Extension Test Suite', () => {
vscode.window.showInformationMessage('Start all tests.');
test('Sample test', () => {
assert.strictEqual(-1, [1, 2, 3].indexOf(5));
assert.strictEqual(-1, [1, 2, 3].indexOf(0));
});
});