Compare commits

...

155 Commits

Author SHA1 Message Date
adamdottv
58b1c58bc5 fix(tui): clear command priority 2025-07-08 19:26:50 -05:00
Dax Raad
d80badc50f ci: ignore chore commits 2025-07-08 20:05:33 -04:00
Dax Raad
75279e5ccf wip: symbols endpoint 2025-07-08 20:05:33 -04:00
Yihui Khuu
7893b84614 Add debounce before exit when using non-leader exit command (#759) 2025-07-08 18:53:38 -05:00
Dax Raad
cfc715bd48 wip: remove excess import 2025-07-08 19:51:09 -04:00
adamdottv
39bcba85a9 chore: vendor clipboard into go package 2025-07-08 18:48:40 -05:00
adamdottv
da3df51316 chore: remove clipboard temp 2025-07-08 18:47:59 -05:00
adamdottv
12190e4efc chore: vendor clipboard into go package 2025-07-08 18:46:42 -05:00
Aiden Cline
d2a9b2f64a fix: documentation typo (#781) 2025-07-08 18:30:46 -05:00
adamdottv
aacadd8a8a fix(tui): panic when reading/writing clipboard on linux 2025-07-08 18:29:45 -05:00
Jay V
969154a473 docs: share page image 2025-07-08 19:24:21 -04:00
Jay V
4d6ca3fab1 docs: share page many model case 2025-07-08 19:08:33 -04:00
Dax Raad
00ea5082e7 add typescript lsp timeout if it fails to start 2025-07-08 18:33:12 -04:00
Dax Raad
4a878b88c0 properly load typescript lsp in subpaths 2025-07-08 18:18:45 -04:00
Dax Raad
6de955847c big rework of LSP system 2025-07-08 18:14:49 -04:00
Jay V
3ba5d528b4 docs: share bugs 2025-07-08 18:14:36 -04:00
Jay V
f99e2b3429 docs: share error part 2025-07-08 18:00:08 -04:00
Jay V
7e4e6f6e51 docs: share page bugs 2025-07-08 17:18:38 -04:00
Jay V
0514f3f43b docs: share image model 2025-07-08 17:18:38 -04:00
Timo Clasen
1e07384364 fix: make compact command interruptible (#691)
Co-authored-by: GitHub Action <action@github.com>
2025-07-08 15:37:25 -05:00
strager
4c4739c422 fix(tool): fix ripgrep invocation on Windows (#700)
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
2025-07-08 15:36:26 -05:00
Rami Chowdhury
2d8b90a6ff feat(storage): ensure storage directory exists and handle paths correctly (#771) 2025-07-08 15:34:11 -05:00
Robb Currall
a2fa7ffa42 fix: support cancelled task state (#775) 2025-07-08 15:33:39 -05:00
Frank Denis
f7d6175283 Add support for the Zig Language Server (ZLS) (#756) 2025-07-08 15:31:11 -05:00
Tommy
9ed187ee52 docs: add terminal requirements (#708) 2025-07-08 15:30:05 -05:00
Gal Schlezinger
14d81e574b [config json schema] declare default values and examples for in-ide documentation (#754) 2025-07-08 15:29:07 -05:00
adamdottv
6efe8cc8df fix: env has to be string 2025-07-08 14:59:03 -05:00
adamdottv
daa5fc916a fix(tui): pasting causes panic on macos 2025-07-08 14:57:17 -05:00
adamdottv
c659496b96 fix(tui): model/provider arg parsing 2025-07-08 14:11:57 -05:00
Timo Clasen
21fbf21cb6 fix(copilot): add vision request header (#773) 2025-07-08 14:01:54 -05:00
adamdottv
f31cbf2744 fix: image reading 2025-07-08 13:02:13 -05:00
Aiden Cline
8322f18e03 fix: display errors when using opencode run ... (#751) 2025-07-08 10:38:11 -05:00
adamdottv
562bdb95e2 fix: include symlinks in ripgrep searches 2025-07-08 10:02:19 -05:00
Dax
a57ce8365d Update STATS.md 2025-07-08 10:30:02 -04:00
adamdottv
0da83ae67e feat(tui): command aliases 2025-07-08 08:20:55 -05:00
adamdottv
662d022a48 feat(tui): paste images and pdfs 2025-07-08 08:09:01 -05:00
GitHub Action
9efef03919 ignore: update download stats 2025-07-08 2025-07-08 12:04:27 +00:00
GitHub Action
7a9fb3fa92 ignore: update download stats 2025-07-08 2025-07-08 10:51:06 +00:00
adamdottv
ea96ead346 feat(tui): handle --model and --prompt flags 2025-07-08 05:50:18 -05:00
Dax Raad
6100a77b85 start file watcher only for tui 2025-07-07 21:05:04 -04:00
Dax Raad
c7a59ee2b1 better handling of aborting sessions 2025-07-07 20:59:00 -04:00
Jay V
a272b58fe9 docs: intro 2025-07-07 17:41:46 -04:00
Dax Raad
9948fcf1b6 fix crash when running on new project 2025-07-07 17:39:52 -04:00
Dax Raad
0d50c867ff fix mcp tools corrupting session 2025-07-07 17:05:16 -04:00
Dax Raad
27f7e02f12 run: truncate prompt 2025-07-07 16:41:42 -04:00
Jay V
0f93ecd564 docs: canonical url 2025-07-07 16:37:00 -04:00
Dax Raad
da909d9684 append piped stdin to prompt 2025-07-07 16:33:21 -04:00
Jay V
facd851b11 docs: dynamic domain 2025-07-07 16:31:15 -04:00
Dax Raad
c51de945a5 Add stdin support to run command
Allow piping content to opencode run when no message arguments are provided, enabling standard Unix pipe patterns for better CLI integration.

🤖 Generated with [opencode](https://opencode.ai)

Co-Authored-By: opencode <noreply@opencode.ai>
2025-07-07 16:29:13 -04:00
Jay V
9253a3ca9e docs: debug 2025-07-07 16:26:23 -04:00
Dax Raad
7cfa297a78 wip: model and prompt flags for tui 2025-07-07 16:24:37 -04:00
Jay V
661b74def6 docs: debug info 2025-07-07 16:13:26 -04:00
Dax Raad
b478e5655c fix interrupt 2025-07-07 16:12:47 -04:00
Dax
f884766445 v2 message format and upgrade to ai sdk v5 (#743)
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Liang-Shih Lin <liangshihlin@proton.me>
Co-authored-by: Dominik Engelhardt <dominikengelhardt@ymail.com>
Co-authored-by: Jay V <air@live.ca>
Co-authored-by: adamdottv <2363879+adamdottv@users.noreply.github.com>
2025-07-07 15:53:43 -04:00
Jay V
76b2e4539c docs: discord 2025-07-07 14:44:37 -04:00
Dominik Engelhardt
d87922c0eb Fix Elixir LSP startup (#726) 2025-07-06 23:37:46 -04:00
Liang-Shih Lin
2446483df5 fix: Skip opencode upgrade if same version (#720) 2025-07-06 23:36:59 -04:00
GitHub Action
f4c453155d Update download stats 2025-07-06 2025-07-06 12:03:56 +00:00
Dax Raad
969ad80ed2 fix openrouter caching with anthropic, should be a lot cheaper 2025-07-05 11:39:54 -04:00
GitHub Action
af064b41d7 Update download stats 2025-07-05 2025-07-05 12:03:56 +00:00
Dax Raad
ea6bfef21a use full filepath 2025-07-04 17:58:03 -04:00
Jay V
107363b1d9 docs: fix show more in share page 2025-07-04 17:57:12 -04:00
Dax Raad
85214d7c59 fix input bar not rendering capital letters 2025-07-04 17:21:51 -04:00
Timo Clasen
997cb2d945 fix(tui): optimistic rendering (#692) 2025-07-04 16:06:57 -05:00
Dax Raad
45b139390c make file attachments work good like 2025-07-04 16:21:26 -04:00
Jay V
994368de15 docs: share fix scrolling again 2025-07-04 13:53:25 -04:00
Jay V
143fd8e076 docs: share improve markdown rendering of ai responses 2025-07-04 13:53:25 -04:00
Dax Raad
06dba28bd6 wip: fix media type 2025-07-04 12:50:52 -04:00
adamdottv
b8d276a049 fix(tui): full paths for attachments 2025-07-04 11:42:22 -05:00
Dax Raad
ee01f01271 file attachments 2025-07-04 12:24:01 -04:00
adamdottv
32d5db4f0a fix(tui): markdown wrapping off sometimes 2025-07-04 11:16:38 -05:00
adamdottv
f6108b7be8 fix(tui): handle pdf and image @ files 2025-07-04 11:13:09 -05:00
adamdottv
94ef341c9d feat(tui): render attachments 2025-07-04 10:55:02 -05:00
adamdottv
f9abc7c84f feat(tui): file attachments 2025-07-04 10:55:02 -05:00
adamdottv
891ed6ebc0 fix(tui): slower startup due to file.status 2025-07-04 10:55:01 -05:00
Dax Raad
163e23a68b removed banned command concept 2025-07-04 11:32:12 -04:00
Vladimir
f13b0af491 docs: Fix invalid json in the mcp example config (#645) 2025-07-04 11:24:13 -04:00
Aiden Cline
4a0be45d3d chore: document instructions configuration option (#670) 2025-07-04 11:22:45 -04:00
Dax Raad
23788674c8 disable snapshots temporarily 2025-07-04 08:45:18 -04:00
GitHub Action
121eb24e73 Update download stats 2025-07-04 2025-07-04 12:26:16 +00:00
Dax Raad
571d60182a improve snapshotting speed further 2025-07-03 21:36:09 -04:00
Jay V
167a9dcaf3 docs: share fix scroll to anchor 2025-07-03 20:30:21 -04:00
Dax Raad
37327259cb ci: ignore 2025-07-03 20:30:02 -04:00
Dax Raad
cdb25656d5 improve snapshot speed 2025-07-03 20:16:25 -04:00
Jay V
25c876caa2 docs: share fix last message not expandable 2025-07-03 19:33:55 -04:00
Dax Raad
cf83e31f23 add elixir lsp support 2025-07-03 19:29:51 -04:00
Dax Raad
3bc238b58b wip: logs 2025-07-03 19:29:51 -04:00
Jay V
b8de69dced docs: fix share page scroll performance 2025-07-03 19:15:38 -04:00
Jay V
e7fcb692a4 docs: tweak page title 2025-07-03 16:23:08 -04:00
Timo Clasen
dae38574ab chore: add dev script (#666) 2025-07-03 14:43:25 -05:00
Dax Raad
ed4f862b49 fix /unshare 2025-07-03 15:34:04 -04:00
adamdottv
fce59db94a chore: simplify completions 2025-07-03 12:48:22 -05:00
Jay V
3e2a0c7281 docs: share handle slow loading pages 2025-07-03 13:15:21 -04:00
adamdottv
5a0910ea79 chore: better local dev with stainless script 2025-07-03 11:49:15 -05:00
adamdottv
1dffabcfda fix(tui): panic on completions failure 2025-07-03 10:53:43 -05:00
adamdottv
c389e0ed43 fix(tui): redundant tool calls in each message in collapsed mode 2025-07-03 10:42:27 -05:00
Dax Raad
204801052a flag for disabling file watcher 2025-07-03 10:37:08 -04:00
Dax Raad
2528d8cb88 increase max retries to 10 2025-07-03 10:32:55 -04:00
adamdottv
6b73ffd1c1 fix(tui): include orphaned tool calls 2025-07-03 09:32:44 -05:00
adamdottv
0eadc50a33 fix(tui): selected message visuals 2025-07-03 09:03:04 -05:00
Dax Raad
aeea84a877 fix webdomain 2025-07-03 09:58:25 -04:00
GitHub Action
a54c5c6298 Update download stats 2025-07-03 2025-07-03 12:26:51 +00:00
adamdottv
8825cd3811 feat(tui): unshare command 2025-07-03 07:09:09 -05:00
adamdottv
3d9a5d9970 fix(tui): always show status bar 2025-07-03 06:53:05 -05:00
adamdottv
1f9e195fa6 fix(tui): better highlight visuals 2025-07-03 06:49:37 -05:00
Craig Andrews
73c012c76c fix: simplify parallel map using channels (#582) 2025-07-03 05:43:10 -05:00
Lev
2ace57404b fix: properly handle utf-8 in diff highlighting (#585) 2025-07-03 05:42:40 -05:00
Dax Raad
8c4b5e088b do not install gopls if go is not installed 2025-07-02 23:59:08 -04:00
Jacob Hands
69920a73d7 fix: use correct opencode bin path when running in development mode (#483) 2025-07-02 23:37:48 -04:00
Timo Clasen
ae76a3467a fix: typescript error (#618) 2025-07-02 23:27:43 -04:00
Adi Yeroslav
701107cda4 Update prompt reference from CLAUDE.md to AGENTS.md (#623) 2025-07-02 23:27:22 -04:00
Aiden Cline
b99565959b feat: configurable instructions (#624) 2025-07-02 23:27:04 -04:00
andrewxt
67aa7ce04d fix mouse scroll events being interpreted as keyboard input (#628) 2025-07-02 23:26:09 -04:00
Dax Raad
c663fbc3ee remove need for glibc 2025-07-02 22:53:04 -04:00
Dax Raad
2090bab537 fix(tui): change messages layout toggle keybinding from <leader>m to <leader>p
🤖 Generated with [opencode](https://opencode.ai)

Co-Authored-By: opencode <noreply@opencode.ai>
2025-07-02 20:06:30 -04:00
Aiden Cline
64d5fff9a3 fix: unawaited promise causes opencode to use unenabled formatter (#625) 2025-07-02 19:19:31 -04:00
Jay V
925f695503 docs: tweak styles 2025-07-02 18:44:05 -04:00
adamdottv
f1c925795d fix: typescript error 2025-07-02 16:08:41 -05:00
adamdottv
c82a060eca feat(tui): file viewer, select messages 2025-07-02 16:08:11 -05:00
Ciaran McAleer
63e783ef79 Changed handling of OpenRouter requests to add some custom headers so that it can see the app (#613)
Co-authored-by: Dax Raad <d@ironbay.co>
2025-07-02 14:43:59 -04:00
Dax Raad
35d6273fb3 wip: session revert/unrevert 2025-07-02 13:10:36 -04:00
Mark Huggins
b89d4a16fd fix: Copilot Premium Requests (#595) 2025-07-02 12:04:53 -04:00
Prashant Choudhary
2799a96032 fix: Ensure shared file previews use truncated content (#607)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2025-07-02 12:04:10 -04:00
Timo Clasen
8f4b79227c fix(formatting): check for enabled formatters (#611) 2025-07-02 12:03:42 -04:00
Dax Raad
c810b6d206 wip: symbols for lsp 2025-07-02 11:35:25 -04:00
Dax Raad
fa35407572 fix lazy loading 2025-07-02 11:18:25 -04:00
Dax Raad
8bbbc07aff fix filewatcher not closing cleanly 2025-07-02 11:15:12 -04:00
GitHub Action
75a21ba3ce Update download stats 2025-07-02 2025-07-02 12:26:24 +00:00
Timo Clasen
0d6fb68a88 fix(tui): no space between agent and user message (#598) 2025-07-02 05:12:49 -05:00
Jean du Plessis
242b886434 fix: Small typo in CLI --model flag description (#577) 2025-07-02 05:10:58 -05:00
Daniel Vélez
caf465a9da chore: rename OpenCode to opencode (#579) 2025-07-02 05:09:51 -05:00
Dax Raad
bbf77c6139 improve ripgrep download 2025-07-01 22:39:17 -04:00
Dax Raad
53b7e04b86 ci: tweaks 2025-07-01 22:25:53 -04:00
Dax Raad
9e75e3ed18 ignore: read deleted files 2025-07-01 20:45:50 -04:00
Dax Raad
6389858d41 ignore: add file status command 2025-07-01 20:44:12 -04:00
Dax Raad
7e5941e14b ignore: add file status command 2025-07-01 20:39:43 -04:00
Dax Raad
c68aeed8d9 ignore: fix file read with diff 2025-07-01 20:08:42 -04:00
Aiden Cline
b199a609a8 fix: handle null case if tool args are empty for todos (#588) 2025-07-01 18:25:23 -05:00
Frank
4a5a93b3f8 Temporarily add admin unshare api 2025-07-01 18:57:08 -04:00
Dax Raad
e99bdcefac fix write tool timeout 2025-07-01 13:50:57 -04:00
Dax Raad
26dcb85de1 add file watcher 2025-07-01 13:45:25 -04:00
Dax Raad
11d042be25 snapshot functionality 2025-07-01 12:28:34 -04:00
adamdottv
33b5fe236a fix(tui): better message rendering performance 2025-07-01 07:57:45 -05:00
GitHub Action
d56991006c Update download stats 2025-07-01 2025-07-01 12:27:09 +00:00
adamdottv
739a9f71c3 fix(tui): layout issues 2025-07-01 06:41:39 -05:00
Adam Spiers
aef81fce0b docs: use correct baseUrl for astro editLink (#507)
Co-authored-by: Adam Spiers <opencode@adamspiers.org>
2025-07-01 05:31:18 -05:00
Timo Clasen
8f3d7b4038 feat: better model dialog with sorting by release date (#563) 2025-07-01 05:28:32 -05:00
Dax Raad
de15e67834 fix lsp diagnostic accurancy 2025-06-30 22:48:32 -04:00
Dax Raad
fea56d8de6 fix loading api key from env for openai compatible providers 2025-06-30 19:07:51 -04:00
Max Rabin
3d71be2b45 Add pyright lsp for Python (#551)
Co-authored-by: Max Rabin <max.rabin@mobileye.com>
2025-06-30 18:17:47 -04:00
adamdottv
58baca2a5b chore: typescript error 2025-06-30 15:46:18 -05:00
adamdottv
ef73926db6 chore: include model release date 2025-06-30 15:46:18 -05:00
Dax Raad
9ad1687f04 optimistically boot lsp servers 2025-06-30 16:45:26 -04:00
Jeremy Mack
c573270e66 chore: remove duplicate EditTool in TOOLS array (#556) 2025-06-30 15:32:15 -04:00
Dax Raad
9ebad68274 fix bash tool extra line 2025-06-30 15:31:30 -04:00
265 changed files with 23200 additions and 7881 deletions

View File

@@ -28,5 +28,5 @@ jobs:
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add STATS.md
git diff --staged --quiet || git commit -m "Update download stats $(date -I)"
git diff --staged --quiet || git commit -m "ignore: update download stats $(date -I)"
git push

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@ node_modules
.env
.idea
.vscode
openapi.json

View File

@@ -9,7 +9,7 @@
</p>
<p align="center">AI coding agent, built for the terminal.</p>
<p align="center">
<a href="https://opencode.ai/docs"><img alt="View docs" src="https://img.shields.io/badge/view-docs-blue?style=flat-square" /></a>
<a href="https://discord.gg/opencode"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
<a href="https://github.com/sst/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/sst/opencode/publish.yml?style=flat-square&branch=dev" /></a>
</p>
@@ -76,4 +76,4 @@ The other confusingly named repo has no relation to this one. You can [read the
---
**Join our community** [YouTube](https://www.youtube.com/c/sst-dev) | [X.com](https://x.com/SST_dev)
**Join our community** [Discord](https://discord.gg/opencode) | [YouTube](https://www.youtube.com/c/sst-dev) | [X.com](https://x.com/SST_dev)

View File

@@ -1,6 +1,13 @@
# 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) |
| 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) |

114
bun.lock
View File

@@ -5,7 +5,7 @@
"name": "opencode",
"devDependencies": {
"prettier": "3.5.3",
"sst": "3.17.6",
"sst": "3.17.8",
},
},
"packages/function": {
@@ -36,6 +36,7 @@
"env-paths": "3.0.0",
"hono": "4.7.10",
"hono-openapi": "0.4.8",
"isomorphic-git": "1.32.1",
"open": "10.1.2",
"remeda": "2.22.3",
"ts-lsp-client": "1.0.3",
@@ -77,11 +78,12 @@
"lang-map": "0.4.0",
"luxon": "3.6.1",
"marked": "15.0.12",
"marked-shiki": "1.2.0",
"rehype-autolink-headings": "7.1.0",
"sharp": "0.32.5",
"shiki": "3.4.2",
"solid-js": "1.9.7",
"toolbeam-docs-theme": "0.3.0",
"toolbeam-docs-theme": "0.4.1",
},
"devDependencies": {
"@types/node": "catalog:",
@@ -94,30 +96,22 @@
"sharp",
"esbuild",
],
"patchedDependencies": {
"ai@4.3.16": "patches/ai@4.3.16.patch",
},
"overrides": {
"zod": "3.24.2",
},
"catalog": {
"@types/node": "22.13.9",
"ai": "4.3.16",
"ai": "5.0.0-beta.7",
"typescript": "5.8.2",
"zod": "3.24.2",
"zod": "3.25.49",
},
"packages": {
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@2.2.10", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-icLGO7Q0NinnHIPgT+y1QjHVwH4HwV+brWbvM+FfCG2Afpa89PyKa3Ret91kGjZpBgM/xnj1B7K5eM+rRlsXQA=="],
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@1.2.12", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-YSzjlko7JvuiyQFmI9RN1tNZdEiZxc+6xld/0tq/VkJaHpEzGAb1yiNxxvmYVcjvfu/PcvCxAAYXmTYQQ63IHQ=="],
"@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="],
"@ai-sdk/gateway": ["@ai-sdk/gateway@1.0.0-beta.3", "", { "dependencies": { "@ai-sdk/provider": "2.0.0-beta.1", "@ai-sdk/provider-utils": "3.0.0-beta.2" }, "peerDependencies": { "zod": "^3.25.49" } }, "sha512-g49gMSkXy94lYvl5LRh438OR/0JCG6ol0jV+iLot7cy5HLltZlGocEuauETBu4b10mDXOd7XIjTEZoQpYFMYLQ=="],
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="],
"@ai-sdk/provider": ["@ai-sdk/provider@2.0.0-beta.1", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Z8SPncMtS3RsoXITmT7NVwrAq6M44dmw0DoUOYJqNNtCu8iMWuxB8Nxsoqpa0uEEy9R1V1ZThJAXTYgjTUxl3w=="],
"@ai-sdk/react": ["@ai-sdk/react@1.2.12", "", { "dependencies": { "@ai-sdk/provider-utils": "2.2.8", "@ai-sdk/ui-utils": "1.2.11", "swr": "^2.2.5", "throttleit": "2.1.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "zod": "^3.23.8" }, "optionalPeers": ["zod"] }, "sha512-jK1IZZ22evPZoQW3vlkZ7wvjYGYF+tRBKXtrcolduIkQ/m/sOAVcVeVDUDvh1T91xCnWCdUGCPZg2avZ90mv3g=="],
"@ai-sdk/ui-utils": ["@ai-sdk/ui-utils@1.2.11", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w=="],
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.0-beta.2", "", { "dependencies": { "@ai-sdk/provider": "2.0.0-beta.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.49" } }, "sha512-H4K+4weOVgWqrDDeAbQWoA4U5mN4WrQPHQFdH7ynQYcnhj/pzctU9Q6mGlR5ESMWxaXxazxlOblSITlXo9bahA=="],
"@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
@@ -461,12 +455,10 @@
"@types/babel__traverse": ["@types/babel__traverse@7.20.7", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng=="],
"@types/bun": ["@types/bun@1.2.17", "", { "dependencies": { "bun-types": "1.2.17" } }, "sha512-l/BYs/JYt+cXA/0+wUhulYJB6a6p//GTPiJ7nV+QHa8iiId4HZmnu/3J/SowP5g0rTiERY2kfGKXEK5Ehltx4Q=="],
"@types/bun": ["@types/bun@1.2.18", "", { "dependencies": { "bun-types": "1.2.18" } }, "sha512-Xf6RaWVheyemaThV0kUfaAUvCNokFr+bH8Jxp+tTZfx7dAPA8z9ePnP9S9+Vspzuxxx9JRAXhnyccRj3GyCMdQ=="],
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
"@types/diff-match-patch": ["@types/diff-match-patch@1.0.36", "", {}, "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg=="],
"@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="],
"@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
@@ -491,6 +483,8 @@
"@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
"@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="],
"@types/sax": ["@types/sax@1.2.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A=="],
"@types/turndown": ["@types/turndown@5.0.5", "", {}, "sha512-TL2IgGgc7B5j78rIccBtlYAnkuv8nUQqhQc+DSYV5j9Be9XOcm/SKOVRuA47xAVI3680Tk9B1d8flK2GWT2+4w=="],
@@ -511,7 +505,7 @@
"acorn-walk": ["acorn-walk@8.3.2", "", {}, "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A=="],
"ai": ["ai@4.3.16", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "@ai-sdk/react": "1.2.12", "@ai-sdk/ui-utils": "1.2.11", "@opentelemetry/api": "1.9.0", "jsondiffpatch": "0.6.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "zod": "^3.23.8" }, "optionalPeers": ["react"] }, "sha512-KUDwlThJ5tr2Vw0A1ZkbDKNME3wzWhuVfAOwIvFUzl1TPVDFAXDFTXio3p+jaKneB+dKNCvFFlolYmmgHttG1g=="],
"ai": ["ai@5.0.0-beta.7", "", { "dependencies": { "@ai-sdk/gateway": "1.0.0-beta.3", "@ai-sdk/provider": "2.0.0-beta.1", "@ai-sdk/provider-utils": "3.0.0-beta.2", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.49" } }, "sha512-oC4KzUJCQPMB7v9rCqL/rVk2ogZvI6lYiXfKjzPYHwa1zIgy329qqRLmAd3mKEDTTG6By1r0zasQu7FKmG+4gw=="],
"ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="],
@@ -541,6 +535,8 @@
"astro-expressive-code": ["astro-expressive-code@0.41.2", "", { "dependencies": { "rehype-expressive-code": "^0.41.2" }, "peerDependencies": { "astro": "^4.0.0-beta || ^5.0.0-beta || ^3.3.0" } }, "sha512-HN0jWTnhr7mIV/2e6uu4PPRNNo/k4UEgTLZqbp3MrHU+caCARveG2yZxaZVBmxyiVdYqW5Pd3u3n2zjnshixbw=="],
"async-lock": ["async-lock@1.4.1", "", {}, "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ=="],
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
"available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
@@ -599,7 +595,7 @@
"buffer": ["buffer@4.9.2", "", { "dependencies": { "base64-js": "^1.0.2", "ieee754": "^1.1.4", "isarray": "^1.0.0" } }, "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg=="],
"bun-types": ["bun-types@1.2.17", "", { "dependencies": { "@types/node": "*" } }, "sha512-ElC7ItwT3SCQwYZDYoAH+q6KT4Fxjl8DtZ6qDulUFBmXA8YB4xo+l54J9ZJN+k2pphfn9vk7kfubeSd5QfTVJQ=="],
"bun-types": ["bun-types@1.2.18", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-04+Eha5NP7Z0A9YgDAzMk5PHR16ZuLVa83b26kH5+cp1qZW4F6FmAURngE7INf4tKOvCE69vYvDEwoNl1tGiWw=="],
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
@@ -633,6 +629,8 @@
"ci-info": ["ci-info@4.2.0", "", {}, "sha512-cYY9mypksY8NRqgDB1XD1RiJL338v/551niynFTGkZOO2LHuB2OmOYxDIe/ttN9AHwrqdum1360G3ald0W9kCg=="],
"clean-git-ref": ["clean-git-ref@2.0.1", "", {}, "sha512-bLSptAy2P0s6hU4PzuIMKmMJJSE6gLXGH1cntDu7bWJUksvuM+7ReOK61mozULErYvP6a15rnYl0zFDef+pyPw=="],
"cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="],
"cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="],
@@ -669,6 +667,8 @@
"cors": ["cors@2.8.5", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g=="],
"crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="],
"cross-fetch": ["cross-fetch@3.2.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q=="],
"crossws": ["crossws@0.3.5", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA=="],
@@ -723,7 +723,7 @@
"diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="],
"diff-match-patch": ["diff-match-patch@1.0.5", "", {}, "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw=="],
"diff3": ["diff3@0.0.3", "", {}, "sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g=="],
"direction": ["direction@2.0.1", "", { "bin": { "direction": "cli.js" } }, "sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA=="],
@@ -791,7 +791,7 @@
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
"eventsource-parser": ["eventsource-parser@3.0.2", "", {}, "sha512-6RxOBZ/cYgd8usLwsEl+EC09Au/9BcmCKYF2/xbml6DNczf7nv0MQb+7BA2F+li6//I+28VNlQR37XfQtcAJuA=="],
"eventsource-parser": ["eventsource-parser@3.0.3", "", {}, "sha512-nVpZkTMM9rF6AQ9gPJpFsNAMt48wIzB5TQgiTLdHiuO8XEDhUgZEhqKlZWXbIzo9VmJ/HvysHqEaVeD5v9TPvA=="],
"exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="],
@@ -939,6 +939,8 @@
"ieee754": ["ieee754@1.1.13", "", {}, "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"import-meta-resolve": ["import-meta-resolve@4.1.0", "", {}, "sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
@@ -987,6 +989,8 @@
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
"isomorphic-git": ["isomorphic-git@1.32.1", "", { "dependencies": { "async-lock": "^1.4.1", "clean-git-ref": "^2.0.1", "crc-32": "^1.2.0", "diff3": "0.0.3", "ignore": "^5.1.4", "minimisted": "^2.0.0", "pako": "^1.0.10", "path-browserify": "^1.0.1", "pify": "^4.0.1", "readable-stream": "^3.4.0", "sha.js": "^2.4.9", "simple-get": "^4.0.1" }, "bin": { "isogit": "cli.cjs" } }, "sha512-NZCS7qpLkCZ1M/IrujYBD31sM6pd/fMVArK4fz4I7h6m0rUW2AsYU7S7zXeABuHL6HIfW6l53b4UQ/K441CQjg=="],
"jmespath": ["jmespath@0.16.0", "", {}, "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw=="],
"jose": ["jose@5.2.3", "", {}, "sha512-KUXdbctm1uHVL8BYhnyHkgp3zDX5KW8ZhAKVFEfUbU2P8Alpzjb+48hHvjOdQIyPshoblhzsuqOwEEAbtHVirA=="],
@@ -1009,8 +1013,6 @@
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"jsondiffpatch": ["jsondiffpatch@0.6.0", "", { "dependencies": { "@types/diff-match-patch": "^1.0.36", "chalk": "^5.3.0", "diff-match-patch": "^1.0.5" }, "bin": { "jsondiffpatch": "bin/jsondiffpatch.js" } }, "sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ=="],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"klona": ["klona@2.0.6", "", {}, "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA=="],
@@ -1037,6 +1039,8 @@
"marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
"marked-shiki": ["marked-shiki@1.2.0", "", { "peerDependencies": { "marked": ">=7.0.0", "shiki": ">=1.0.0" } }, "sha512-N924hp8veE6Mc91g5/kCNVoTU7TkeJfB2G2XEWb+k1fVA0Bck2T0rVt93d39BlOYH6ohP4Q9BFlPk+UkblhXbg=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"mdast-util-definitions": ["mdast-util-definitions@6.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ=="],
@@ -1169,6 +1173,8 @@
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"minimisted": ["minimisted@2.0.1", "", { "dependencies": { "minimist": "^1.2.5" } }, "sha512-1oPjfuLQa2caorJUM8HV8lGgWCc0qqAO1MNv/k05G4qslmsndV/5WdNZrqCiyqiz3wohia2Ij2B7w2Dr7/IyrA=="],
"mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
"mri": ["mri@1.1.4", "", {}, "sha512-6y7IjGPm8AzlvoUrwAaw1tLnUBudaS3752vcd8JtrpGGQn+rXIe63LFVHm/YMwtqAuh+LJPCFdlLYPWM1nYn6w=="],
@@ -1247,7 +1253,7 @@
"pagefind": ["pagefind@1.3.0", "", { "optionalDependencies": { "@pagefind/darwin-arm64": "1.3.0", "@pagefind/darwin-x64": "1.3.0", "@pagefind/linux-arm64": "1.3.0", "@pagefind/linux-x64": "1.3.0", "@pagefind/windows-x64": "1.3.0" }, "bin": { "pagefind": "lib/runner/bin.cjs" } }, "sha512-8KPLGT5g9s+olKMRTU9LFekLizkVIu9tes90O1/aigJ0T5LmyPqTzGJrETnSw3meSYg58YH7JTzhTTW/3z6VAw=="],
"pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
@@ -1257,6 +1263,8 @@
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
"path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
@@ -1267,6 +1275,8 @@
"picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="],
"pify": ["pify@4.0.1", "", {}, "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g=="],
"pino": ["pino@7.11.0", "", { "dependencies": { "atomic-sleep": "^1.0.0", "fast-redact": "^3.0.0", "on-exit-leak-free": "^0.2.0", "pino-abstract-transport": "v0.5.0", "pino-std-serializers": "^4.0.0", "process-warning": "^1.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.1.0", "safe-stable-stringify": "^2.1.0", "sonic-boom": "^2.2.1", "thread-stream": "^0.15.1" }, "bin": { "pino": "bin.js" } }, "sha512-dMACeu63HtRLmCG8VKdy4cShCPKaYDR4youZqoSWLxl5Gu99HUw8bw75thbPv9Nip+H+QYX8o3ZJbTdVZZ2TVg=="],
"pino-abstract-transport": ["pino-abstract-transport@0.5.0", "", { "dependencies": { "duplexify": "^4.1.2", "split2": "^4.0.0" } }, "sha512-+KAgmVeqXYbTtU2FScx1XS3kNyfZ5TrXY07V96QnUSFqo2gAqlvmaxH67Lj7SWazqsMabf+58ctdTcBgnOLUOQ=="],
@@ -1319,8 +1329,6 @@
"rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
"react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="],
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
@@ -1417,6 +1425,8 @@
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"sha.js": ["sha.js@2.4.11", "", { "dependencies": { "inherits": "^2.0.1", "safe-buffer": "^5.0.1" }, "bin": { "sha.js": "./bin.js" } }, "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ=="],
"sharp": ["sharp@0.32.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.2", "node-addon-api": "^6.1.0", "prebuild-install": "^7.1.1", "semver": "^7.5.4", "simple-get": "^4.0.1", "tar-fs": "^3.0.4", "tunnel-agent": "^0.6.0" } }, "sha512-0dap3iysgDkNaPOaOL4X/0akdu0ma62GcdC2NBQ+93eqpePdDdr2/LM0sFdDSMmN7yS+odyZtPsb7tx/cYBKnQ=="],
"shiki": ["shiki@3.4.2", "", { "dependencies": { "@shikijs/core": "3.4.2", "@shikijs/engine-javascript": "3.4.2", "@shikijs/engine-oniguruma": "3.4.2", "@shikijs/langs": "3.4.2", "@shikijs/themes": "3.4.2", "@shikijs/types": "3.4.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-wuxzZzQG8kvZndD7nustrNFIKYJ1jJoWIPaBpVe2+KHSvtzMi4SBjOxrigs8qeqce/l3U0cwiC+VAkLKSunHQQ=="],
@@ -1455,23 +1465,23 @@
"split2": ["split2@3.2.2", "", { "dependencies": { "readable-stream": "^3.0.0" } }, "sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg=="],
"sst": ["sst@3.17.6", "", { "dependencies": { "aws-sdk": "2.1692.0", "aws4fetch": "1.0.18", "jose": "5.2.3", "opencontrol": "0.0.6", "openid-client": "5.6.4" }, "optionalDependencies": { "sst-darwin-arm64": "3.17.6", "sst-darwin-x64": "3.17.6", "sst-linux-arm64": "3.17.6", "sst-linux-x64": "3.17.6", "sst-linux-x86": "3.17.6", "sst-win32-arm64": "3.17.6", "sst-win32-x64": "3.17.6", "sst-win32-x86": "3.17.6" }, "bin": { "sst": "bin/sst.mjs" } }, "sha512-p+AcqwfYQUdkxeRjCikQoTMviPCBiGoU7M0vcV6GDVmVis8hzhVw4EFfHTafZC+aWfy1Ke2UQi66vZlEVWuEqA=="],
"sst": ["sst@3.17.8", "", { "dependencies": { "aws-sdk": "2.1692.0", "aws4fetch": "1.0.18", "jose": "5.2.3", "opencontrol": "0.0.6", "openid-client": "5.6.4" }, "optionalDependencies": { "sst-darwin-arm64": "3.17.8", "sst-darwin-x64": "3.17.8", "sst-linux-arm64": "3.17.8", "sst-linux-x64": "3.17.8", "sst-linux-x86": "3.17.8", "sst-win32-arm64": "3.17.8", "sst-win32-x64": "3.17.8", "sst-win32-x86": "3.17.8" }, "bin": { "sst": "bin/sst.mjs" } }, "sha512-P/a9/ZsjtQRrTBerBMO1ODaVa5HVTmNLrQNJiYvu2Bgd0ov+vefQeHv6oima8HLlPwpDIPS2gxJk8BZrTZMfCA=="],
"sst-darwin-arm64": ["sst-darwin-arm64@3.17.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-6tb7KlcPR7PTi3ofQv8dX/n6Jf7pNP9VfrnYL4HBWnWrcYaZeJ5MWobILfIJ/y2jHgoqmg9e5C3266Eds0JQyw=="],
"sst-darwin-arm64": ["sst-darwin-arm64@3.17.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-50P6YRMnZVItZUfB0+NzqMww2mmm4vB3zhTVtWUtGoXeiw78g1AEnVlmS28gYXPHM1P987jTvR7EON9u9ig/Dg=="],
"sst-darwin-x64": ["sst-darwin-x64@3.17.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-lFakq6/EgTuBSjbl8Kry4pfgAPEIyn6o7ZkyRz3hz5331wUaX88yfjs3tL9JQ8Ey6jBUYxwhP/Q1n7fzIG046g=="],
"sst-darwin-x64": ["sst-darwin-x64@3.17.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-P0pnMHCmpkpcsxkWpilmeoD79LkbkoIcv6H0aeM9ArT/71/JBhvqH+HjMHSJCzni/9uR6er+nH5F+qol0UO6Bw=="],
"sst-linux-arm64": ["sst-linux-arm64@3.17.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-SdTxXMbTEdiwOqp37w31kXv97vHqSx3oK9h/76lKg7V9k5JxPJ6JMefPLhoKWwK0Zh6AndY2zo2oRoEv4SIaDw=="],
"sst-linux-arm64": ["sst-linux-arm64@3.17.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-vun54YA/UzprCu9p8BC4rMwFU5Cj9xrHAHYLYUp/yq4H0pfmBIiQM62nsfIKizRThe/TkBFy60EEi9myf6raYA=="],
"sst-linux-x64": ["sst-linux-x64@3.17.6", "", { "os": "linux", "cpu": "x64" }, "sha512-qneh7uWDiTUYx8X1Y3h2YVw3SJ0ybBBlRrVybIvCM09JqQ8+qq/XjKXGzA/3/EF0Jr7Ug8cARSn9CwxhdQGN7Q=="],
"sst-linux-x64": ["sst-linux-x64@3.17.8", "", { "os": "linux", "cpu": "x64" }, "sha512-HqByCaLE2gEJbM20P1QRd+GqDMAiieuU53FaZA1F+AGxQi+kR82NWjrPqFcMj4dMYg8w/TWXuV+G5+PwoUmpDw=="],
"sst-linux-x86": ["sst-linux-x86@3.17.6", "", { "os": "linux", "cpu": "none" }, "sha512-pU3D5OeqnmfxGqN31DxuwWnc1OayxhkErnITHhZ39D0MTiwbIgCapH26FuLW8B08/uxJWG8djUlOboCRhSBvWA=="],
"sst-linux-x86": ["sst-linux-x86@3.17.8", "", { "os": "linux", "cpu": "none" }, "sha512-bCd6QM3MejfSmdvg8I/k+aUJQIZEQJg023qmN78fv00vwlAtfECvY7tjT9E2m3LDp33pXrcRYbFOQzPu+tWFfA=="],
"sst-win32-arm64": ["sst-win32-arm64@3.17.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-Rr3RTYWAsH9sM9CbM/sAZCk7dB1OsSAljjJuuHMvdSAYW3RDpXEza0PBJGxnBID2eOrpswEchzMPL2d8LtL7oA=="],
"sst-win32-arm64": ["sst-win32-arm64@3.17.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-pilx0n8gm4aHJae/vNiqIwZkWF3tdwWzD/ON7hkytw+CVSZ0FXtyFW/yO/+2u3Yw0Kj0lSWPnUqYgm/eHPLwQA=="],
"sst-win32-x64": ["sst-win32-x64@3.17.6", "", { "os": "win32", "cpu": "x64" }, "sha512-yZ3roxwI0Wve9PFzdrrF1kfzCmIMFCCoa8qKeXY7LxCJ4QQIqHbCOccLK1Wv/MIU/mcZHWXTQVCLHw77uaa0GQ=="],
"sst-win32-x64": ["sst-win32-x64@3.17.8", "", { "os": "win32", "cpu": "x64" }, "sha512-Jb0FVRyiOtESudF1V8ucW65PuHrx/iOHUamIO0JnbujWNHZBTRPB2QHN1dbewgkueYDaCmyS8lvuIImLwYJnzQ=="],
"sst-win32-x86": ["sst-win32-x86@3.17.6", "", { "os": "win32", "cpu": "none" }, "sha512-zV7TJWPJN9PmIXr15iXFSs0tbGsa52oBR3+xiKrUj2qj9XsZe7HBFwskRnHyiFq0durZY9kk9ZtoVlpuUuzr1g=="],
"sst-win32-x86": ["sst-win32-x86@3.17.8", "", { "os": "win32", "cpu": "none" }, "sha512-oVmFa/PoElQmfnGJlB0w6rPXiYuldiagO6AbrLMT/6oAnWerLQ8Uhv9tJWfMh3xtPLImQLTjxDo1v0AIzEv9QA=="],
"stacktracey": ["stacktracey@2.1.8", "", { "dependencies": { "as-table": "^1.0.36", "get-source": "^2.0.12" } }, "sha512-Kpij9riA+UNg7TnphqjH7/CzctQ/owJGNbFkfEeve4Z4uxT5+JapVLFXcsurIfN34gnTWZNJ/f7NMG0E8JDzTw=="],
@@ -1503,8 +1513,6 @@
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"swr": ["swr@2.3.3", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-dshNvs3ExOqtZ6kJBaAsabhPdHyeY4P2cKwRCniDVifBMoG/SVI7tfLWqPXriVspf2Rg4tPzXJTnwaihIeFw2A=="],
"tar-fs": ["tar-fs@3.0.9", "", { "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" }, "optionalDependencies": { "bare-fs": "^4.0.1", "bare-path": "^3.0.0" } }, "sha512-XF4w9Xp+ZQgifKakjZYmFdkLoSWd34VGKcsTCwlNWM7QG3ZbaxnTsaBwnjFZqHRf/rROxaR8rXnbtwdvaDI+lA=="],
"tar-stream": ["tar-stream@3.1.7", "", { "dependencies": { "b4a": "^1.6.4", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ=="],
@@ -1513,8 +1521,6 @@
"thread-stream": ["thread-stream@0.15.2", "", { "dependencies": { "real-require": "^0.1.0" } }, "sha512-UkEhKIg2pD+fjkHQKyJO3yoIvAP3N6RlNFt2dUhcS1FGvCD1cQa1M/PGknCLFIyZdtJOWQjejp7bdNqmN7zwdA=="],
"throttleit": ["throttleit@2.1.0", "", {}, "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw=="],
"tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="],
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
@@ -1525,7 +1531,7 @@
"token-types": ["token-types@6.0.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-lbDrTLVsHhOMljPscd0yitpozq7Ga2M5Cvez5AjGg8GASBjtt6iERCAJ93yommPmz62fb45oFIXHEZ3u9bfJEA=="],
"toolbeam-docs-theme": ["toolbeam-docs-theme@0.3.0", "", { "peerDependencies": { "@astrojs/starlight": "^0.34.3", "astro": "^5.7.13" } }, "sha512-qlBkKRp8HVYV7p7jaG9lT2lvQY7c8b9czZ0tnsJUrN2TBTtEyFJymCdkhhpZNC9U4oGZ7lLk0glRJHrndWvVsg=="],
"toolbeam-docs-theme": ["toolbeam-docs-theme@0.4.1", "", { "peerDependencies": { "@astrojs/starlight": "^0.34.3", "astro": "^5.7.13" } }, "sha512-lTI4dHZaVNQky29m7sb36Oy4tWPwxsCuFxFjF8hgGW0vpV+S6qPvI9SwsJFvdE/OHO5DoI7VMbryV1pxZHkkHQ=="],
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
@@ -1599,8 +1605,6 @@
"url": ["url@0.10.3", "", { "dependencies": { "punycode": "1.3.2", "querystring": "0.2.0" } }, "sha512-hzSUW2q06EqL1gKM/a+obYHLIO6ct2hwPuviqTTOcfFVc61UbfJ2Q32+uGL/HCPxKqrdGB5QUwIe7UqlDgwsOQ=="],
"use-sync-external-store": ["use-sync-external-store@1.5.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A=="],
"util": ["util@0.12.5", "", { "dependencies": { "inherits": "^2.0.3", "is-arguments": "^1.0.4", "is-generator-function": "^1.0.7", "is-typed-array": "^1.1.3", "which-typed-array": "^1.1.2" } }, "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
@@ -1677,7 +1681,7 @@
"youch": ["youch@3.3.4", "", { "dependencies": { "cookie": "^0.7.1", "mustache": "^4.2.0", "stacktracey": "^2.1.8" } }, "sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg=="],
"zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="],
"zod": ["zod@3.25.49", "", {}, "sha512-JMMPMy9ZBk3XFEdbM3iL1brx4NUSejd6xr3ELrrGEfGb355gjhiAWtG3K5o+AViV/3ZfkIrCzXsZn6SbLwTR8Q=="],
"zod-openapi": ["zod-openapi@4.2.4", "", { "peerDependencies": { "zod": "^3.21.4" } }, "sha512-tsrQpbpqFCXqVXUzi3TPwFhuMtLN3oNZobOtYnK6/5VkXsNdnIgyNr4r8no4wmYluaxzN3F7iS+8xCW8BmMQ8g=="],
@@ -1689,12 +1693,22 @@
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="],
"@ai-sdk/amazon-bedrock/aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="],
"@ai-sdk/anthropic/@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="],
"@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="],
"@ampproject/remapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
"@astrojs/mdx/@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.2", "", { "dependencies": { "@astrojs/internal-helpers": "0.6.1", "@astrojs/prism": "3.3.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.1.0", "js-yaml": "^4.1.0", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^3.2.1", "smol-toml": "^1.3.1", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.1", "vfile": "^6.0.3" } }, "sha512-bO35JbWpVvyKRl7cmSJD822e8YA8ThR/YbUsciWNA7yTcqpIAL2hJDToWP5KcZBWxGT6IOdOkHSXARSNZc4l/Q=="],
"@astrojs/sitemap/zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="],
"@aws-crypto/crc32/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
@@ -1713,6 +1727,8 @@
"@jridgewell/gen-mapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
"@modelcontextprotocol/sdk/zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="],
"@openauthjs/openauth/@standard-schema/spec": ["@standard-schema/spec@1.0.0-beta.3", "", {}, "sha512-0ifF3BjA1E8SY9C+nUew8RefNOIq0cDlYALPty4rhUm8Rrl6tCM8hBT4bhGhx7I7iXD0uAgt50lgo8dD73ACMw=="],
"@openauthjs/openauth/aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="],
@@ -1749,10 +1765,14 @@
"astro/sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="],
"astro/zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="],
"babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="],
"bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
"eventsource/eventsource-parser": ["eventsource-parser@3.0.2", "", {}, "sha512-6RxOBZ/cYgd8usLwsEl+EC09Au/9BcmCKYF2/xbml6DNczf7nv0MQb+7BA2F+li6//I+28VNlQR37XfQtcAJuA=="],
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"get-source/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
@@ -1765,8 +1785,12 @@
"miniflare/sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="],
"miniflare/zod": ["zod@3.22.3", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="],
"opencontrol/hono": ["hono@4.7.4", "", {}, "sha512-Pst8FuGqz3L7tFF+u9Pu70eI0xa5S3LPUmrNd5Jm8nTHze9FxLTK9Kaj5g/k4UcwuJSXTP65SyHOPLrffpcAJg=="],
"opencontrol/zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="],
"opencontrol/zod-to-json-schema": ["zod-to-json-schema@3.24.3", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-HIAfWdYIt1sssHfYZFCXp4rU1w2r8hVVXYIlmoa0r0gABLs5di3RCqPU5DDROogVz1pAdYBaz7HK5n9pSUNs3A=="],
"openid-client/jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="],
@@ -1793,6 +1817,8 @@
"token-types/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"unicode-trie/pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
"unstorage/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.1.0", "", {}, "sha512-6TDy/abTQk+zDGYazgbIPc+4JoXdwC8NHU9Pbn4UJP1fehUyZmM4RHp5IthX7A6L5KS30PRui+j+tbbMMMafdw=="],

View File

@@ -9,6 +9,9 @@ const bucket = new sst.cloudflare.Bucket("Bucket")
export const api = new sst.cloudflare.Worker("Api", {
domain: `api.${domain}`,
handler: "packages/function/src/api.ts",
environment: {
WEB_DOMAIN: domain,
},
url: true,
link: [bucket],
transform: {
@@ -36,6 +39,8 @@ new sst.cloudflare.x.Astro("Web", {
domain,
path: "packages/web",
environment: {
// For astro config
SST_STAGE: $app.stage,
VITE_API_URL: api.url,
},
})

View File

@@ -1,5 +1,11 @@
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"weather": {
"type": "local",
"command": ["opencode", "x", "@h1deya/mcp-server-weather"]
}
},
"experimental": {
"hook": {
"file_edited": {

View File

@@ -5,7 +5,9 @@
"type": "module",
"packageManager": "bun@1.2.14",
"scripts": {
"dev": "bun run packages/opencode/src/index.ts",
"typecheck": "bun run --filter='*' typecheck",
"stainless": "./scripts/stainless",
"postinstall": "./scripts/hooks"
},
"workspaces": {
@@ -15,13 +17,13 @@
"catalog": {
"typescript": "5.8.2",
"@types/node": "22.13.9",
"zod": "3.24.2",
"ai": "4.3.16"
"zod": "3.25.49",
"ai": "5.0.0-beta.7"
}
},
"devDependencies": {
"prettier": "3.5.3",
"sst": "3.17.6"
"sst": "3.17.8"
},
"repository": {
"type": "git",
@@ -29,10 +31,8 @@
},
"license": "MIT",
"prettier": {
"semi": false
},
"overrides": {
"zod": "3.24.2"
"semi": false,
"printWidth": 120
},
"trustedDependencies": [
"esbuild",

View File

@@ -4,6 +4,7 @@ import { randomUUID } from "node:crypto"
type Env = {
SYNC_SERVER: DurableObjectNamespace<SyncServer>
Bucket: R2Bucket
WEB_DOMAIN: string
}
export class SyncServer extends DurableObject<Env> {
@@ -35,13 +36,9 @@ export class SyncServer extends DurableObject<Env> {
ws.close(code, "Durable Object is closing WebSocket")
}
async publish(secret: string, key: string, content: any) {
if (secret !== (await this.getSecret())) throw new Error("Invalid secret")
async publish(key: string, content: any) {
const sessionID = await this.getSessionID()
if (
!key.startsWith(`session/info/${sessionID}`) &&
!key.startsWith(`session/message/${sessionID}/`)
)
if (!key.startsWith(`session/info/${sessionID}`) && !key.startsWith(`session/message/${sessionID}/`))
return new Response("Error: Invalid key", { status: 400 })
// store message
@@ -76,6 +73,10 @@ export class SyncServer extends DurableObject<Env> {
.map(([key, content]) => ({ key, content }))
}
public async assertSecret(secret: string) {
if (secret !== (await this.getSecret())) throw new Error("Invalid secret")
}
private async getSecret() {
return this.ctx.storage.get<string>("secret")
}
@@ -84,15 +85,19 @@ export class SyncServer extends DurableObject<Env> {
return this.ctx.storage.get<string>("sessionID")
}
async clear(secret: string) {
await this.assertSecret(secret)
async clear() {
const sessionID = await this.getSessionID()
const list = await this.env.Bucket.list({
prefix: `session/message/${sessionID}/`,
limit: 1000,
})
for (const item of list.objects) {
await this.env.Bucket.delete(item.key)
}
await this.env.Bucket.delete(`session/info/${sessionID}`)
await this.ctx.storage.deleteAll()
}
private async assertSecret(secret: string) {
if (secret !== (await this.getSecret())) throw new Error("Invalid secret")
}
static shortName(id: string) {
return id.substring(id.length - 8)
}
@@ -120,7 +125,7 @@ export default {
return new Response(
JSON.stringify({
secret,
url: "https://opencode.ai/s/" + short,
url: `https://${env.WEB_DOMAIN}/s/${short}`,
}),
{
headers: { "Content-Type": "application/json" },
@@ -134,7 +139,17 @@ export default {
const secret = body.secret
const id = env.SYNC_SERVER.idFromName(SyncServer.shortName(sessionID))
const stub = env.SYNC_SERVER.get(id)
await stub.clear(secret)
await stub.assertSecret(secret)
await stub.clear()
return new Response(JSON.stringify({}), {
headers: { "Content-Type": "application/json" },
})
}
if (request.method === "POST" && method === "share_delete_admin") {
const id = env.SYNC_SERVER.idFromName("oVF8Rsiv")
const stub = env.SYNC_SERVER.get(id)
await stub.clear()
return new Response(JSON.stringify({}), {
headers: { "Content-Type": "application/json" },
})
@@ -150,7 +165,8 @@ export default {
const name = SyncServer.shortName(body.sessionID)
const id = env.SYNC_SERVER.idFromName(name)
const stub = env.SYNC_SERVER.get(id)
await stub.publish(body.secret, body.key, body.content)
await stub.assertSecret(body.secret)
await stub.publish(body.key, body.content)
return new Response(JSON.stringify({}), {
headers: { "Content-Type": "application/json" },
})
@@ -165,8 +181,7 @@ export default {
}
const id = url.searchParams.get("id")
console.log("share_poll", id)
if (!id)
return new Response("Error: Share ID is required", { status: 400 })
if (!id) return new Response("Error: Share ID is required", { status: 400 })
const stub = env.SYNC_SERVER.get(env.SYNC_SERVER.idFromName(id))
return stub.fetch(request)
}
@@ -174,8 +189,7 @@ export default {
if (request.method === "GET" && method === "share_data") {
const id = url.searchParams.get("id")
console.log("share_data", id)
if (!id)
return new Response("Error: Share ID is required", { status: 400 })
if (!id) return new Response("Error: Share ID is required", { status: 400 })
const stub = env.SYNC_SERVER.get(env.SYNC_SERVER.idFromName(id))
const data = await stub.getData()

View File

@@ -1,4 +1,3 @@
node_modules
research
dist
gen

View File

@@ -49,7 +49,7 @@ else
done
if [ -z "$resolved" ]; then
printf "It seems that your package manager failed to install the right version of the OpenCode CLI for your platform. You can try manually installing the \"%s\" package\n" "$name" >&2
printf "It seems that your package manager failed to install the right version of the opencode CLI for your platform. You can try manually installing the \"%s\" package\n" "$name" >&2
exit 1
fi
fi

View File

@@ -48,9 +48,9 @@ set "current_dir=%parent_dir%"
goto :search_loop
:not_found
echo It seems that your package manager failed to install the right version of the OpenCode CLI for your platform. You can try manually installing the "%name%" package >&2
echo It seems that your package manager failed to install the right version of the opencode CLI for your platform. You can try manually installing the "%name%" package >&2
exit /b 1
:execute
rem Execute the binary with all arguments
"%resolved%" %*
"%resolved%" %*

View File

@@ -14,111 +14,267 @@
"properties": {
"leader": {
"type": "string",
"description": "Leader key for keybind combinations"
"default": "ctrl+x",
"description": "Leader key for keybind combinations\n\ndefault: `ctrl+x`",
"examples": [
"ctrl+x"
]
},
"help": {
"app_help": {
"type": "string",
"description": "Show help dialog"
"default": "<leader>h",
"description": "Show help dialog\n\ndefault: `<leader>h`",
"examples": [
"<leader>h"
]
},
"editor_open": {
"type": "string",
"description": "Open external editor"
"default": "<leader>e",
"description": "Open external editor\n\ndefault: `<leader>e`",
"examples": [
"<leader>e"
]
},
"session_new": {
"type": "string",
"description": "Create a new session"
"default": "<leader>n",
"description": "Create a new session\n\ndefault: `<leader>n`",
"examples": [
"<leader>n"
]
},
"session_list": {
"type": "string",
"description": "List all sessions"
"default": "<leader>l",
"description": "List all sessions\n\ndefault: `<leader>l`",
"examples": [
"<leader>l"
]
},
"session_share": {
"type": "string",
"description": "Share current session"
"default": "<leader>s",
"description": "Share current session\n\ndefault: `<leader>s`",
"examples": [
"<leader>s"
]
},
"session_unshare": {
"type": "string",
"default": "<leader>u",
"description": "Unshare current session\n\ndefault: `<leader>u`",
"examples": [
"<leader>u"
]
},
"session_interrupt": {
"type": "string",
"description": "Interrupt current session"
"default": "esc",
"description": "Interrupt current session\n\ndefault: `esc`",
"examples": [
"esc"
]
},
"session_compact": {
"type": "string",
"description": "Toggle compact mode for session"
"default": "<leader>c",
"description": "Compact the session\n\ndefault: `<leader>c`",
"examples": [
"<leader>c"
]
},
"tool_details": {
"type": "string",
"description": "Show tool details"
"default": "<leader>d",
"description": "Toggle tool details\n\ndefault: `<leader>d`",
"examples": [
"<leader>d"
]
},
"model_list": {
"type": "string",
"description": "List available models"
"default": "<leader>m",
"description": "List available models\n\ndefault: `<leader>m`",
"examples": [
"<leader>m"
]
},
"theme_list": {
"type": "string",
"description": "List available themes"
"default": "<leader>t",
"description": "List available themes\n\ndefault: `<leader>t`",
"examples": [
"<leader>t"
]
},
"file_list": {
"type": "string",
"default": "<leader>f",
"description": "List files\n\ndefault: `<leader>f`",
"examples": [
"<leader>f"
]
},
"file_close": {
"type": "string",
"default": "esc",
"description": "Close file\n\ndefault: `esc`",
"examples": [
"esc"
]
},
"file_search": {
"type": "string",
"default": "<leader>/",
"description": "Search file\n\ndefault: `<leader>/`",
"examples": [
"<leader>/"
]
},
"file_diff_toggle": {
"type": "string",
"default": "<leader>v",
"description": "Split/unified diff\n\ndefault: `<leader>v`",
"examples": [
"<leader>v"
]
},
"project_init": {
"type": "string",
"description": "Initialize project configuration"
"default": "<leader>i",
"description": "Create/update AGENTS.md\n\ndefault: `<leader>i`",
"examples": [
"<leader>i"
]
},
"input_clear": {
"type": "string",
"description": "Clear input field"
"default": "ctrl+c",
"description": "Clear input field\n\ndefault: `ctrl+c`",
"examples": [
"ctrl+c"
]
},
"input_paste": {
"type": "string",
"description": "Paste from clipboard"
"default": "ctrl+v",
"description": "Paste from clipboard\n\ndefault: `ctrl+v`",
"examples": [
"ctrl+v"
]
},
"input_submit": {
"type": "string",
"description": "Submit input"
"default": "enter",
"description": "Submit input\n\ndefault: `enter`",
"examples": [
"enter"
]
},
"input_newline": {
"type": "string",
"description": "Insert newline in input"
},
"history_previous": {
"type": "string",
"description": "Navigate to previous history item"
},
"history_next": {
"type": "string",
"description": "Navigate to next history item"
"default": "shift+enter,ctrl+j",
"description": "Insert newline in input\n\ndefault: `shift+enter,ctrl+j`",
"examples": [
"shift+enter,ctrl+j"
]
},
"messages_page_up": {
"type": "string",
"description": "Scroll messages up by one page"
"default": "pgup",
"description": "Scroll messages up by one page\n\ndefault: `pgup`",
"examples": [
"pgup"
]
},
"messages_page_down": {
"type": "string",
"description": "Scroll messages down by one page"
"default": "pgdown",
"description": "Scroll messages down by one page\n\ndefault: `pgdown`",
"examples": [
"pgdown"
]
},
"messages_half_page_up": {
"type": "string",
"description": "Scroll messages up by half page"
"default": "ctrl+alt+u",
"description": "Scroll messages up by half page\n\ndefault: `ctrl+alt+u`",
"examples": [
"ctrl+alt+u"
]
},
"messages_half_page_down": {
"type": "string",
"description": "Scroll messages down by half page"
"default": "ctrl+alt+d",
"description": "Scroll messages down by half page\n\ndefault: `ctrl+alt+d`",
"examples": [
"ctrl+alt+d"
]
},
"messages_previous": {
"type": "string",
"description": "Navigate to previous message"
"default": "ctrl+up",
"description": "Navigate to previous message\n\ndefault: `ctrl+up`",
"examples": [
"ctrl+up"
]
},
"messages_next": {
"type": "string",
"description": "Navigate to next message"
"default": "ctrl+down",
"description": "Navigate to next message\n\ndefault: `ctrl+down`",
"examples": [
"ctrl+down"
]
},
"messages_first": {
"type": "string",
"description": "Navigate to first message"
"default": "ctrl+g",
"description": "Navigate to first message\n\ndefault: `ctrl+g`",
"examples": [
"ctrl+g"
]
},
"messages_last": {
"type": "string",
"description": "Navigate to last message"
"default": "ctrl+alt+g",
"description": "Navigate to last message\n\ndefault: `ctrl+alt+g`",
"examples": [
"ctrl+alt+g"
]
},
"messages_layout_toggle": {
"type": "string",
"default": "<leader>p",
"description": "Toggle layout\n\ndefault: `<leader>p`",
"examples": [
"<leader>p"
]
},
"messages_copy": {
"type": "string",
"default": "<leader>y",
"description": "Copy message\n\ndefault: `<leader>y`",
"examples": [
"<leader>y"
]
},
"messages_revert": {
"type": "string",
"default": "<leader>r",
"description": "Revert message\n\ndefault: `<leader>r`",
"examples": [
"<leader>r"
]
},
"app_exit": {
"type": "string",
"description": "Exit the application"
"default": "ctrl+c,<leader>q",
"description": "Exit the application\n\ndefault: `ctrl+c,<leader>q`",
"examples": [
"ctrl+c,<leader>q"
]
}
},
"additionalProperties": false,
@@ -171,9 +327,15 @@
"additionalProperties": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"release_date": {
"type": "string"
},
"attachment": {
"type": "boolean"
},
@@ -202,7 +364,10 @@
"type": "number"
}
},
"required": ["input", "output"],
"required": [
"input",
"output"
],
"additionalProperties": false
},
"limit": {
@@ -215,12 +380,12 @@
"type": "number"
}
},
"required": ["context", "output"],
"required": [
"context",
"output"
],
"additionalProperties": false
},
"id": {
"type": "string"
},
"options": {
"type": "object",
"additionalProperties": {}
@@ -234,7 +399,9 @@
"additionalProperties": {}
}
},
"required": ["models"],
"required": [
"models"
],
"additionalProperties": false
},
"description": "Custom provider configurations and model overrides"
@@ -270,7 +437,10 @@
"description": "Enable or disable the MCP server on startup"
}
},
"required": ["type", "command"],
"required": [
"type",
"command"
],
"additionalProperties": false
},
{
@@ -290,13 +460,23 @@
"description": "Enable or disable the MCP server on startup"
}
},
"required": ["type", "url"],
"required": [
"type",
"url"
],
"additionalProperties": false
}
]
},
"description": "MCP (Model Context Protocol) server configurations"
},
"instructions": {
"type": "array",
"items": {
"type": "string"
},
"description": "Additional instruction files or patterns to include"
},
"experimental": {
"type": "object",
"properties": {
@@ -323,7 +503,9 @@
}
}
},
"required": ["command"],
"required": [
"command"
],
"additionalProperties": false
}
}
@@ -346,7 +528,9 @@
}
}
},
"required": ["command"],
"required": [
"command"
],
"additionalProperties": false
}
}
@@ -359,4 +543,4 @@
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
}

View File

@@ -37,6 +37,7 @@
"env-paths": "3.0.0",
"hono": "4.7.10",
"hono-openapi": "0.4.8",
"isomorphic-git": "1.32.1",
"open": "10.1.2",
"remeda": "2.22.3",
"ts-lsp-client": "1.0.3",

View File

@@ -40,7 +40,7 @@ for (const [os, arch] of targets) {
console.log(`building ${os}-${arch}`)
const name = `${pkg.name}-${os}-${arch}`
await $`mkdir -p dist/${name}/bin`
await $`GOOS=${os} GOARCH=${GOARCH[arch]} go build -ldflags="-s -w -X main.Version=${version}" -o ../opencode/dist/${name}/bin/tui ../tui/cmd/opencode/main.go`.cwd(
await $`CGO_ENABLED=0 GOOS=${os} GOARCH=${GOARCH[arch]} go build -ldflags="-s -w -X main.Version=${version}" -o ../opencode/dist/${name}/bin/tui ../tui/cmd/opencode/main.go`.cwd(
"../tui",
)
await $`bun build --define OPENCODE_VERSION="'${version}'" --compile --minify --target=bun-${os}-${arch} --outfile=dist/${name}/bin/opencode ./src/index.ts ./dist/${name}/bin/tui`
@@ -57,8 +57,7 @@ for (const [os, arch] of targets) {
2,
),
)
if (!dry)
await $`cd dist/${name} && bun publish --access public --tag ${npmTag}`
if (!dry) await $`cd dist/${name} && bun publish --access public --tag ${npmTag}`
optionalDependencies[name] = version
}
@@ -82,8 +81,7 @@ await Bun.file(`./dist/${pkg.name}/package.json`).write(
2,
),
)
if (!dry)
await $`cd ./dist/${pkg.name} && bun publish --access public --tag ${npmTag}`
if (!dry) await $`cd ./dist/${pkg.name} && bun publish --access public --tag ${npmTag}`
if (!snapshot) {
// Github Release
@@ -91,15 +89,11 @@ if (!snapshot) {
await $`cd dist/${key}/bin && zip -r ../../${key}.zip *`
}
const previous = await fetch(
"https://api.github.com/repos/sst/opencode/releases/latest",
)
const previous = await fetch("https://api.github.com/repos/sst/opencode/releases/latest")
.then((res) => res.json())
.then((data) => data.tag_name)
const commits = await fetch(
`https://api.github.com/repos/sst/opencode/compare/${previous}...HEAD`,
)
const commits = await fetch(`https://api.github.com/repos/sst/opencode/compare/${previous}...HEAD`)
.then((res) => res.json())
.then((data) => data.commits || [])
@@ -109,33 +103,22 @@ if (!snapshot) {
const lower = x.toLowerCase()
return (
!lower.includes("ignore:") &&
!lower.includes("chore:") &&
!lower.includes("ci:") &&
!lower.includes("wip:") &&
!lower.includes("docs:") &&
!lower.includes("doc:")
)
})
.join("\n")
if (!dry)
await $`gh release create v${version} --title "v${version}" --notes ${notes} ./dist/*.zip`
if (!dry) await $`gh release create v${version} --title "v${version}" --notes ${notes} ./dist/*.zip`
// Calculate SHA values
const arm64Sha =
await $`sha256sum ./dist/opencode-linux-arm64.zip | cut -d' ' -f1`
.text()
.then((x) => x.trim())
const x64Sha =
await $`sha256sum ./dist/opencode-linux-x64.zip | cut -d' ' -f1`
.text()
.then((x) => x.trim())
const macX64Sha =
await $`sha256sum ./dist/opencode-darwin-x64.zip | cut -d' ' -f1`
.text()
.then((x) => x.trim())
const macArm64Sha =
await $`sha256sum ./dist/opencode-darwin-arm64.zip | cut -d' ' -f1`
.text()
.then((x) => x.trim())
const arm64Sha = await $`sha256sum ./dist/opencode-linux-arm64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
const x64Sha = await $`sha256sum ./dist/opencode-linux-x64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
const macX64Sha = await $`sha256sum ./dist/opencode-darwin-x64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
const macArm64Sha = await $`sha256sum ./dist/opencode-darwin-arm64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
// AUR package
const pkgbuild = [
@@ -169,9 +152,7 @@ if (!snapshot) {
for (const pkg of ["opencode", "opencode-bin"]) {
await $`rm -rf ./dist/aur-${pkg}`
await $`git clone ssh://aur@aur.archlinux.org/${pkg}.git ./dist/aur-${pkg}`
await Bun.file(`./dist/aur-${pkg}/PKGBUILD`).write(
pkgbuild.replace("${pkg}", pkg),
)
await Bun.file(`./dist/aur-${pkg}/PKGBUILD`).write(pkgbuild.replace("${pkg}", pkg))
await $`cd ./dist/aur-${pkg} && makepkg --printsrcinfo > .SRCINFO`
await $`cd ./dist/aur-${pkg} && git add PKGBUILD .SRCINFO`
await $`cd ./dist/aur-${pkg} && git commit -m "Update to v${version}"`

View File

@@ -4,5 +4,30 @@ import "zod-openapi/extend"
import { Config } from "../src/config/config"
import { zodToJsonSchema } from "zod-to-json-schema"
const result = zodToJsonSchema(Config.Info)
const result = zodToJsonSchema(Config.Info, {
/**
* We'll use the `default` values of the field as the only value in `examples`.
* This will ensure no docs are needed to be read, as the configuration is
* self-documenting.
*
* See https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.9.5
*/
postProcess(jsonSchema) {
const schema = jsonSchema as typeof jsonSchema & {
examples?: unknown[]
}
if (schema && typeof schema === "object" && "type" in schema && schema.type === "string" && schema?.default) {
if (!schema.examples) {
schema.examples = [schema.default]
}
schema.description = [schema.description || "", `default: \`${schema.default}\``]
.filter(Boolean)
.join("\n\n")
.trim()
}
return jsonSchema
},
})
await Bun.write("config.schema.json", JSON.stringify(result, null, 2))

View File

@@ -36,29 +36,23 @@ export namespace App {
services: Map<any, { state: any; shutdown?: (input: any) => Promise<void> }>
}>("app")
export const use = ctx.use
const APP_JSON = "app.json"
export type Input = {
cwd: string
}
export async function provide<T>(
input: Input,
cb: (app: App.Info) => Promise<T>,
) {
export const provideExisting = ctx.provide
export async function provide<T>(input: Input, cb: (app: App.Info) => Promise<T>) {
log.info("creating", {
cwd: input.cwd,
})
const git = await Filesystem.findUp(".git", input.cwd).then(([x]) =>
x ? path.dirname(x) : undefined,
)
const git = await Filesystem.findUp(".git", input.cwd).then(([x]) => (x ? path.dirname(x) : undefined))
log.info("git", { git })
const data = path.join(
Global.Path.data,
"project",
git ? directory(git) : "global",
)
const data = path.join(Global.Path.data, "project", git ? directory(git) : "global")
const stateFile = Bun.file(path.join(data, APP_JSON))
const state = (await stateFile.json().catch(() => ({}))) as {
initialized: number
@@ -96,13 +90,16 @@ export namespace App {
}
return ctx.provide(app, async () => {
const result = await cb(app.info)
for (const [key, entry] of app.services.entries()) {
if (!entry.shutdown) continue
log.info("shutdown", { name: key })
await entry.shutdown?.(await entry.state)
try {
const result = await cb(app.info)
return result
} finally {
for (const [key, entry] of app.services.entries()) {
if (!entry.shutdown) continue
log.info("shutdown", { name: key })
await entry.shutdown?.(await entry.state)
}
}
return result
})
}

View File

@@ -10,14 +10,8 @@ export namespace AuthAnthropic {
url.searchParams.set("code", "true")
url.searchParams.set("client_id", CLIENT_ID)
url.searchParams.set("response_type", "code")
url.searchParams.set(
"redirect_uri",
"https://console.anthropic.com/oauth/code/callback",
)
url.searchParams.set(
"scope",
"org:create_api_key user:profile user:inference",
)
url.searchParams.set("redirect_uri", "https://console.anthropic.com/oauth/code/callback")
url.searchParams.set("scope", "org:create_api_key user:profile user:inference")
url.searchParams.set("code_challenge", pkce.challenge)
url.searchParams.set("code_challenge_method", "S256")
url.searchParams.set("state", pkce.verifier)
@@ -57,20 +51,17 @@ export namespace AuthAnthropic {
const info = await Auth.get("anthropic")
if (!info || info.type !== "oauth") return
if (info.access && info.expires > Date.now()) return info.access
const response = await fetch(
"https://console.anthropic.com/v1/oauth/token",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
grant_type: "refresh_token",
refresh_token: info.refresh,
client_id: CLIENT_ID,
}),
const response = await fetch("https://console.anthropic.com/v1/oauth/token", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
)
body: JSON.stringify({
grant_type: "refresh_token",
refresh_token: info.refresh,
client_id: CLIENT_ID,
}),
})
if (!response.ok) return
const json = await response.json()
await Auth.set("anthropic", {

View File

@@ -4,9 +4,7 @@ import path from "path"
export const AuthCopilot = lazy(async () => {
const file = Bun.file(path.join(Global.Path.state, "plugin", "copilot.ts"))
const response = fetch(
"https://raw.githubusercontent.com/sst/opencode-github-copilot/refs/heads/main/auth.ts",
)
const response = fetch("https://raw.githubusercontent.com/sst/opencode-github-copilot/refs/heads/main/auth.ts")
.then((x) => Bun.write(file, x))
.catch(() => {})

View File

@@ -122,10 +122,7 @@ export namespace AuthGithubCopilot {
return tokenData.token
}
export const DeviceCodeError = NamedError.create(
"DeviceCodeError",
z.object({}),
)
export const DeviceCodeError = NamedError.create("DeviceCodeError", z.object({}))
export const TokenExchangeError = NamedError.create(
"TokenExchangeError",

View File

@@ -8,10 +8,7 @@ import { readableStreamToText } from "bun"
export namespace BunProc {
const log = Log.create({ service: "bun" })
export async function run(
cmd: string[],
options?: Bun.SpawnOptions.OptionsObject<any, any, any>,
) {
export async function run(cmd: string[], options?: Bun.SpawnOptions.OptionsObject<any, any, any>) {
log.info("running", {
cmd: [which(), ...cmd],
...options,
@@ -26,9 +23,17 @@ export namespace BunProc {
BUN_BE_BUN: "1",
},
})
const code = await result.exited;
const stdout = result.stdout ? typeof result.stdout === "number" ? result.stdout : await readableStreamToText(result.stdout) : undefined
const stderr = result.stderr ? typeof result.stderr === "number" ? result.stderr : await readableStreamToText(result.stderr) : undefined
const code = await result.exited
const stdout = result.stdout
? typeof result.stdout === "number"
? result.stdout
: await readableStreamToText(result.stdout)
: undefined
const stderr = result.stderr
? typeof result.stderr === "number"
? result.stderr
: await readableStreamToText(result.stderr)
: undefined
log.info("done", {
code,
stdout,
@@ -61,7 +66,7 @@ export namespace BunProc {
if (parsed.dependencies[pkg] === version) return mod
parsed.dependencies[pkg] = version
await Bun.write(pkgjson, JSON.stringify(parsed, null, 2))
await BunProc.run(["install", "--registry=https://registry.npmjs.org"], {
await BunProc.run(["install", "--cwd", Global.Path.cache, "--registry=https://registry.npmjs.org"], {
cwd: Global.Path.cache,
}).catch((e) => {
throw new InstallFailedError(

View File

@@ -18,10 +18,7 @@ export namespace Bus {
const registry = new Map<string, EventDefinition>()
export function event<Type extends string, Properties extends ZodType>(
type: Type,
properties: Properties,
) {
export function event<Type extends string, Properties extends ZodType>(type: Type, properties: Properties) {
const result = {
type,
properties,
@@ -72,10 +69,7 @@ export namespace Bus {
export function subscribe<Definition extends EventDefinition>(
def: Definition,
callback: (event: {
type: Definition["type"]
properties: z.infer<Definition["properties"]>
}) => void,
callback: (event: { type: Definition["type"]; properties: z.infer<Definition["properties"]> }) => void,
) {
return raw(def.type, callback)
}

View File

@@ -1,16 +1,15 @@
import { App } from "../app/app"
import { ConfigHooks } from "../config/hooks"
import { Format } from "../format"
import { LSP } from "../lsp"
import { Share } from "../share/share"
export async function bootstrap<T>(
input: App.Input,
cb: (app: App.Info) => Promise<T>,
) {
export async function bootstrap<T>(input: App.Input, cb: (app: App.Info) => Promise<T>) {
return App.provide(input, async (app) => {
Share.init()
Format.init()
ConfigHooks.init()
LSP.init()
return cb(app)
})

View File

@@ -15,11 +15,7 @@ export const AuthCommand = cmd({
command: "auth",
describe: "manage credentials",
builder: (yargs) =>
yargs
.command(AuthLoginCommand)
.command(AuthLogoutCommand)
.command(AuthListCommand)
.demandCommand(),
yargs.command(AuthLoginCommand).command(AuthLogoutCommand).command(AuthListCommand).demandCommand(),
async handler() {},
})
@@ -31,9 +27,7 @@ export const AuthListCommand = cmd({
UI.empty()
const authPath = path.join(Global.Path.data, "auth.json")
const homedir = os.homedir()
const displayPath = authPath.startsWith(homedir)
? authPath.replace(homedir, "~")
: authPath
const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath
prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
const results = await Auth.all().then((x) => Object.entries(x))
const database = await ModelsDev.get()
@@ -114,8 +108,7 @@ export const AuthLoginCommand = cmd({
if (provider === "other") {
provider = await prompts.text({
message: "Enter provider id",
validate: (x) =>
x.match(/^[a-z-]+$/) ? undefined : "a-z and hyphens only",
validate: (x) => (x.match(/^[a-z-]+$/) ? undefined : "a-z and hyphens only"),
})
if (prompts.isCancel(provider)) throw new UI.CancelledError()
provider = provider.replace(/^@ai-sdk\//, "")
@@ -186,17 +179,13 @@ export const AuthLoginCommand = cmd({
await new Promise((resolve) => setTimeout(resolve, 10))
const deviceInfo = await copilot.authorize()
prompts.note(
`Please visit: ${deviceInfo.verification}\nEnter code: ${deviceInfo.user}`,
)
prompts.note(`Please visit: ${deviceInfo.verification}\nEnter code: ${deviceInfo.user}`)
const spinner = prompts.spinner()
spinner.start("Waiting for authorization...")
while (true) {
await new Promise((resolve) =>
setTimeout(resolve, deviceInfo.interval * 1000),
)
await new Promise((resolve) => setTimeout(resolve, deviceInfo.interval * 1000))
const response = await copilot.poll(deviceInfo.device)
if (response.status === "pending") continue
if (response.status === "success") {
@@ -248,12 +237,7 @@ export const AuthLogoutCommand = cmd({
const providerID = await prompts.select({
message: "Select provider",
options: credentials.map(([key, value]) => ({
label:
(database[key]?.name || key) +
UI.Style.TEXT_DIM +
" (" +
value.type +
")",
label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
value: key,
})),
})

View File

@@ -1,37 +0,0 @@
import { App } from "../../app/app"
import { Ripgrep } from "../../file/ripgrep"
import { LSP } from "../../lsp"
import { cmd } from "./cmd"
export const DebugCommand = cmd({
command: "debug",
builder: (yargs) =>
yargs.command(DiagnosticsCommand).command(TreeCommand).demandCommand(),
async handler() {},
})
const DiagnosticsCommand = cmd({
command: "diagnostics <file>",
builder: (yargs) =>
yargs.positional("file", { type: "string", demandOption: true }),
async handler(args) {
await App.provide({ cwd: process.cwd() }, async () => {
await LSP.touchFile(args.file, true)
console.log(await LSP.diagnostics())
})
},
})
const TreeCommand = cmd({
command: "tree",
builder: (yargs) =>
yargs.option("limit", {
type: "number",
}),
async handler(args) {
await App.provide({ cwd: process.cwd() }, async () => {
const app = App.info()
console.log(await Ripgrep.tree({ cwd: app.path.cwd, limit: args.limit }))
})
},
})

View File

@@ -0,0 +1,36 @@
import { File } from "../../../file"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
const FileReadCommand = cmd({
command: "read <path>",
builder: (yargs) =>
yargs.positional("path", {
type: "string",
demandOption: true,
description: "File path to read",
}),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
const content = await File.read(args.path)
console.log(content)
})
},
})
const FileStatusCommand = cmd({
command: "status",
builder: (yargs) => yargs,
async handler() {
await bootstrap({ cwd: process.cwd() }, async () => {
const status = await File.status()
console.log(JSON.stringify(status, null, 2))
})
},
})
export const FileCommand = cmd({
command: "file",
builder: (yargs) => yargs.command(FileReadCommand).command(FileStatusCommand).demandCommand(),
async handler() {},
})

View File

@@ -0,0 +1,26 @@
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
import { FileCommand } from "./file"
import { LSPCommand } from "./lsp"
import { RipgrepCommand } from "./ripgrep"
import { SnapshotCommand } from "./snapshot"
export const DebugCommand = cmd({
command: "debug",
builder: (yargs) =>
yargs
.command(LSPCommand)
.command(RipgrepCommand)
.command(FileCommand)
.command(SnapshotCommand)
.command({
command: "wait",
async handler() {
await bootstrap({ cwd: process.cwd() }, async () => {
await new Promise((resolve) => setTimeout(resolve, 1_000 * 60 * 60 * 24))
})
},
})
.demandCommand(),
async handler() {},
})

View File

@@ -0,0 +1,33 @@
import { LSP } from "../../../lsp"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
import { Log } from "../../../util/log"
export const LSPCommand = cmd({
command: "lsp",
builder: (yargs) => yargs.command(DiagnosticsCommand).command(SymbolsCommand).demandCommand(),
async handler() {},
})
const DiagnosticsCommand = cmd({
command: "diagnostics <file>",
builder: (yargs) => yargs.positional("file", { type: "string", demandOption: true }),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
await LSP.touchFile(args.file, true)
console.log(await LSP.diagnostics())
})
},
})
export const SymbolsCommand = cmd({
command: "symbols <query>",
builder: (yargs) => yargs.positional("query", { type: "string", demandOption: true }),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
using _ = Log.Default.time("symbols")
const results = await LSP.workspaceSymbol(args.query)
console.log(JSON.stringify(results, null, 2))
})
},
})

View File

@@ -0,0 +1,82 @@
import { App } from "../../../app/app"
import { Ripgrep } from "../../../file/ripgrep"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
export const RipgrepCommand = cmd({
command: "rg",
builder: (yargs) => yargs.command(TreeCommand).command(FilesCommand).command(SearchCommand).demandCommand(),
async handler() {},
})
const TreeCommand = cmd({
command: "tree",
builder: (yargs) =>
yargs.option("limit", {
type: "number",
}),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
const app = App.info()
console.log(await Ripgrep.tree({ cwd: app.path.cwd, limit: args.limit }))
})
},
})
const FilesCommand = cmd({
command: "files",
builder: (yargs) =>
yargs
.option("query", {
type: "string",
description: "Filter files by query",
})
.option("glob", {
type: "string",
description: "Glob pattern to match files",
})
.option("limit", {
type: "number",
description: "Limit number of results",
}),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
const app = App.info()
const files = await Ripgrep.files({
cwd: app.path.cwd,
query: args.query,
glob: args.glob ? [args.glob] : undefined,
limit: args.limit,
})
console.log(files.join("\n"))
})
},
})
const SearchCommand = cmd({
command: "search <pattern>",
builder: (yargs) =>
yargs
.positional("pattern", {
type: "string",
demandOption: true,
description: "Search pattern",
})
.option("glob", {
type: "array",
description: "File glob patterns",
})
.option("limit", {
type: "number",
description: "Limit number of results",
}),
async handler(args) {
const results = await Ripgrep.search({
cwd: process.cwd(),
pattern: args.pattern,
glob: args.glob as string[] | undefined,
limit: args.limit,
})
console.log(JSON.stringify(results, null, 2))
},
})

View File

@@ -0,0 +1,35 @@
import { Snapshot } from "../../../snapshot"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
export const SnapshotCommand = cmd({
command: "snapshot",
builder: (yargs) => yargs.command(SnapshotCreateCommand).command(SnapshotRestoreCommand).demandCommand(),
async handler() {},
})
export const SnapshotCreateCommand = cmd({
command: "create",
async handler() {
await bootstrap({ cwd: process.cwd() }, async () => {
const result = await Snapshot.create("test")
console.log(result)
})
},
})
export const SnapshotRestoreCommand = cmd({
command: "restore <commit>",
builder: (yargs) =>
yargs.positional("commit", {
type: "string",
description: "commit",
demandOption: true,
}),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
await Snapshot.restore("test", args.commit)
console.log("restored")
})
},
})

View File

@@ -10,9 +10,6 @@ export const GenerateCommand = {
const dir = "gen"
await fs.rmdir(dir, { recursive: true }).catch(() => {})
await fs.mkdir(dir, { recursive: true })
await Bun.write(
path.join(dir, "openapi.json"),
JSON.stringify(specs, null, 2),
)
await Bun.write(path.join(dir, "openapi.json"), JSON.stringify(specs, null, 2))
},
} satisfies CommandModule

View File

@@ -2,12 +2,12 @@ import type { Argv } from "yargs"
import { Bus } from "../../bus"
import { Provider } from "../../provider/provider"
import { Session } from "../../session"
import { Message } from "../../session/message"
import { UI } from "../ui"
import { cmd } from "./cmd"
import { Flag } from "../../flag/flag"
import { Config } from "../../config/config"
import { bootstrap } from "../bootstrap"
import { MessageV2 } from "../../session/message-v2"
const TOOL: Record<string, [string, string]> = {
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
@@ -54,7 +54,10 @@ export const RunCommand = cmd({
})
},
handler: async (args) => {
const message = args.message.join(" ")
let message = args.message.join(" ")
if (!process.stdin.isTTY) message += "\n" + (await Bun.stdin.text())
await bootstrap({ cwd: process.cwd() }, async () => {
const session = await (async () => {
if (args.continue) {
@@ -78,27 +81,19 @@ export const RunCommand = cmd({
UI.empty()
UI.println(UI.logo())
UI.empty()
UI.println(UI.Style.TEXT_NORMAL_BOLD + "> ", message)
const displayMessage = message.length > 300 ? message.slice(0, 300) + "..." : message
UI.println(UI.Style.TEXT_NORMAL_BOLD + "> ", displayMessage)
UI.empty()
const cfg = await Config.get()
if (cfg.autoshare || Flag.OPENCODE_AUTO_SHARE || args.share) {
await Session.share(session.id)
UI.println(
UI.Style.TEXT_INFO_BOLD +
"~ https://opencode.ai/s/" +
session.id.slice(-8),
)
UI.println(UI.Style.TEXT_INFO_BOLD + "~ https://opencode.ai/s/" + session.id.slice(-8))
}
UI.empty()
const { providerID, modelID } = args.model
? Provider.parseModel(args.model)
: await Provider.defaultModel()
UI.println(
UI.Style.TEXT_NORMAL_BOLD + "@ ",
UI.Style.TEXT_NORMAL + `${providerID}/${modelID}`,
)
const { providerID, modelID } = args.model ? Provider.parseModel(args.model) : await Provider.defaultModel()
UI.println(UI.Style.TEXT_NORMAL_BOLD + "@ ", UI.Style.TEXT_NORMAL + `${providerID}/${modelID}`)
UI.empty()
function printEvent(color: string, type: string, title: string) {
@@ -110,24 +105,13 @@ export const RunCommand = cmd({
)
}
Bus.subscribe(Message.Event.PartUpdated, async (evt) => {
Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => {
if (evt.properties.sessionID !== session.id) return
const part = evt.properties.part
const message = await Session.getMessage(
evt.properties.sessionID,
evt.properties.messageID,
)
if (
part.type === "tool-invocation" &&
part.toolInvocation.state === "result"
) {
const metadata = message.metadata.tool[part.toolInvocation.toolCallId]
const [tool, color] = TOOL[part.toolInvocation.toolName] ?? [
part.toolInvocation.toolName,
UI.Style.TEXT_INFO_BOLD,
]
printEvent(color, tool, metadata?.title || "Unknown")
if (part.type === "tool" && part.state.status === "completed") {
const [tool, color] = TOOL[part.tool] ?? [part.tool, UI.Style.TEXT_INFO_BOLD]
printEvent(color, tool, part.state.title || "Unknown")
}
if (part.type === "text") {
@@ -141,6 +125,20 @@ export const RunCommand = cmd({
}
})
let errorMsg: string | undefined
Bus.subscribe(Session.Event.Error, async (evt) => {
const { sessionID, error } = evt.properties
if (sessionID !== session.id || !error) return
let err = String(error.name)
if ("data" in error && error.data && "message" in error.data) {
err = error.data.message
}
errorMsg = errorMsg ? errorMsg + "\n" + err : err
UI.error(err)
})
const result = await Session.chat({
sessionID: session.id,
providerID,
@@ -156,6 +154,7 @@ export const RunCommand = cmd({
if (isPiped) {
const match = result.parts.findLast((x) => x.type === "text")
if (match) process.stdout.write(match.text)
if (errorMsg) process.stdout.write(errorMsg)
}
UI.empty()
})

View File

@@ -1,7 +1,7 @@
import { App } from "../../app/app"
import { Provider } from "../../provider/provider"
import { Server } from "../../server/server"
import { Share } from "../../share/share"
import { bootstrap } from "../bootstrap"
import { cmd } from "./cmd"
export const ServeCommand = cmd({
@@ -23,7 +23,7 @@ export const ServeCommand = cmd({
describe: "starts a headless opencode server",
handler: async (args) => {
const cwd = process.cwd()
await App.provide({ cwd }, async () => {
await bootstrap({ cwd }, async () => {
const providers = await Provider.list()
if (Object.keys(providers).length === 0) {
return "needs_provider"
@@ -38,9 +38,7 @@ export const ServeCommand = cmd({
hostname,
})
console.log(
`opencode server listening on http://${server.hostname}:${server.port}`,
)
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
await new Promise(() => {})

View File

@@ -9,15 +9,28 @@ import fs from "fs/promises"
import { Installation } from "../../installation"
import { Config } from "../../config/config"
import { Bus } from "../../bus"
import { Log } from "../../util/log"
import { FileWatcher } from "../../file/watch"
export const TuiCommand = cmd({
command: "$0 [project]",
describe: "start opencode tui",
builder: (yargs) =>
yargs.positional("project", {
type: "string",
describe: "path to start opencode in",
}),
yargs
.positional("project", {
type: "string",
describe: "path to start opencode in",
})
.option("model", {
type: "string",
alias: ["m"],
describe: "model to use in the format of provider/model",
})
.option("prompt", {
alias: ["p"],
type: "string",
describe: "prompt to use",
}),
handler: async (args) => {
while (true) {
const cwd = args.project ? path.resolve(args.project) : process.cwd()
@@ -28,6 +41,7 @@ export const TuiCommand = cmd({
return
}
const result = await bootstrap({ cwd }, async (app) => {
FileWatcher.init()
const providers = await Provider.list()
if (Object.keys(providers).length === 0) {
return "needs_provider"
@@ -39,9 +53,7 @@ export const TuiCommand = cmd({
})
let cmd = ["go", "run", "./main.go"]
let cwd = Bun.fileURLToPath(
new URL("../../../../tui/cmd/opencode", import.meta.url),
)
let cwd = Bun.fileURLToPath(new URL("../../../../tui/cmd/opencode", import.meta.url))
if (Bun.embeddedFiles.length > 0) {
const blob = Bun.embeddedFiles[0] as File
let binaryName = blob.name
@@ -57,14 +69,22 @@ export const TuiCommand = cmd({
cwd = process.cwd()
cmd = [binary]
}
Log.Default.info("tui", {
cmd,
})
const proc = Bun.spawn({
cmd: [...cmd, ...process.argv.slice(2)],
cmd: [
...cmd,
...(args.model ? ["--model", args.model] : []),
...(args.prompt ? ["--prompt", args.prompt] : []),
],
cwd,
stdout: "inherit",
stderr: "inherit",
stdin: "inherit",
env: {
...process.env,
CGO_ENABLED: "0",
OPENCODE_SERVER: server.url.toString(),
OPENCODE_APP_INFO: JSON.stringify(app),
},
@@ -100,7 +120,7 @@ export const TuiCommand = cmd({
UI.empty()
UI.println(UI.logo(" "))
const result = await Bun.spawn({
cmd: [process.execPath, "auth", "login"],
cmd: [...getOpencodeCommand(), "auth", "login"],
cwd: process.cwd(),
stdout: "inherit",
stderr: "inherit",
@@ -112,3 +132,25 @@ export const TuiCommand = cmd({
}
},
})
/**
* Get the correct command to run opencode CLI
* In development: ["bun", "run", "packages/opencode/src/index.ts"]
* In production: ["/path/to/opencode"]
*/
function getOpencodeCommand(): string[] {
// Check if OPENCODE_BIN_PATH is set (used by shell wrapper scripts)
if (process.env["OPENCODE_BIN_PATH"]) {
return [process.env["OPENCODE_BIN_PATH"]]
}
const execPath = process.execPath.toLowerCase()
if (Installation.isDev()) {
// In development, use bun to run the TypeScript entry point
return [execPath, "run", process.argv[1]]
}
// In production, use the current executable path
return [process.execPath]
}

View File

@@ -27,22 +27,26 @@ export const UpgradeCommand = {
const detectedMethod = await Installation.method()
const method = (args.method as Installation.Method) ?? detectedMethod
if (method === "unknown") {
prompts.log.error(
`opencode is installed to ${process.execPath} and seems to be managed by a package manager`,
)
prompts.log.error(`opencode is installed to ${process.execPath} and seems to be managed by a package manager`)
prompts.outro("Done")
return
}
prompts.log.info("Using method: " + method)
const target = args.target ?? (await Installation.latest())
if (Installation.VERSION === target) {
prompts.log.warn(`opencode upgrade skipped: ${target} is already installed`)
prompts.outro("Done")
return
}
prompts.log.info(`From ${Installation.VERSION}${target}`)
const spinner = prompts.spinner()
spinner.start("Upgrading...")
const err = await Installation.upgrade(method, target).catch((err) => err)
if (err) {
spinner.stop("Upgrade failed")
if (err instanceof Installation.UpgradeFailedError)
prompts.log.error(err.data.stderr)
if (err instanceof Installation.UpgradeFailedError) prompts.log.error(err.data.stderr)
else if (err instanceof Error) prompts.log.error(err.message)
prompts.outro("Done")
return

View File

@@ -5,14 +5,11 @@ import { UI } from "./ui"
export function FormatError(input: unknown) {
if (MCP.Failed.isInstance(input))
return `MCP server "${input.data.name}" failed. Note, opencode does not support MCP authentication yet.`
if (Config.JsonError.isInstance(input))
return `Config file at ${input.data.path} is not valid JSON`
if (Config.JsonError.isInstance(input)) return `Config file at ${input.data.path} is not valid JSON`
if (Config.InvalidError.isInstance(input))
return [
`Config file at ${input.data.path} is invalid`,
...(input.data.issues?.map(
(issue) => "↳ " + issue.message + " " + issue.path.join("."),
) ?? []),
...(input.data.issues?.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")) ?? []),
].join("\n")
if (UI.CancelledError.isInstance(input)) return ""

View File

@@ -29,18 +29,12 @@ export namespace Config {
export const McpLocal = z
.object({
type: z.literal("local").describe("Type of MCP server connection"),
command: z
.string()
.array()
.describe("Command and arguments to run the MCP server"),
command: z.string().array().describe("Command and arguments to run the MCP server"),
environment: z
.record(z.string(), z.string())
.optional()
.describe("Environment variables to set when running the MCP server"),
enabled: z
.boolean()
.optional()
.describe("Enable or disable the MCP server on startup"),
enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"),
})
.strict()
.openapi({
@@ -51,10 +45,7 @@ export namespace Config {
.object({
type: z.literal("remote").describe("Type of MCP server connection"),
url: z.string().describe("URL of the remote MCP server"),
enabled: z
.boolean()
.optional()
.describe("Enable or disable the MCP server on startup"),
enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"),
})
.strict()
.openapi({
@@ -66,69 +57,43 @@ export namespace Config {
export const Keybinds = z
.object({
leader: z
.string()
.optional()
.describe("Leader key for keybind combinations"),
help: z.string().optional().describe("Show help dialog"),
editor_open: z.string().optional().describe("Open external editor"),
session_new: z.string().optional().describe("Create a new session"),
session_list: z.string().optional().describe("List all sessions"),
session_share: z.string().optional().describe("Share current session"),
session_interrupt: z
.string()
.optional()
.describe("Interrupt current session"),
session_compact: z
.string()
.optional()
.describe("Toggle compact mode for session"),
tool_details: z.string().optional().describe("Show tool details"),
model_list: z.string().optional().describe("List available models"),
theme_list: z.string().optional().describe("List available themes"),
project_init: z
.string()
.optional()
.describe("Initialize project configuration"),
input_clear: z.string().optional().describe("Clear input field"),
input_paste: z.string().optional().describe("Paste from clipboard"),
input_submit: z.string().optional().describe("Submit input"),
input_newline: z.string().optional().describe("Insert newline in input"),
history_previous: z
.string()
.optional()
.describe("Navigate to previous history item"),
history_next: z
.string()
.optional()
.describe("Navigate to next history item"),
messages_page_up: z
.string()
.optional()
.describe("Scroll messages up by one page"),
messages_page_down: z
.string()
.optional()
.describe("Scroll messages down by one page"),
messages_half_page_up: z
.string()
.optional()
.describe("Scroll messages up by half page"),
leader: z.string().optional().default("ctrl+x").describe("Leader key for keybind combinations"),
app_help: z.string().optional().default("<leader>h").describe("Show help dialog"),
editor_open: z.string().optional().default("<leader>e").describe("Open external editor"),
session_new: z.string().optional().default("<leader>n").describe("Create a new session"),
session_list: z.string().optional().default("<leader>l").describe("List all sessions"),
session_share: z.string().optional().default("<leader>s").describe("Share current session"),
session_unshare: z.string().optional().default("<leader>u").describe("Unshare current session"),
session_interrupt: z.string().optional().default("esc").describe("Interrupt current session"),
session_compact: z.string().optional().default("<leader>c").describe("Compact the session"),
tool_details: z.string().optional().default("<leader>d").describe("Toggle tool details"),
model_list: z.string().optional().default("<leader>m").describe("List available models"),
theme_list: z.string().optional().default("<leader>t").describe("List available themes"),
file_list: z.string().optional().default("<leader>f").describe("List files"),
file_close: z.string().optional().default("esc").describe("Close file"),
file_search: z.string().optional().default("<leader>/").describe("Search file"),
file_diff_toggle: z.string().optional().default("<leader>v").describe("Split/unified diff"),
project_init: z.string().optional().default("<leader>i").describe("Create/update AGENTS.md"),
input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"),
input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"),
input_submit: z.string().optional().default("enter").describe("Submit input"),
input_newline: z.string().optional().default("shift+enter,ctrl+j").describe("Insert newline in input"),
messages_page_up: z.string().optional().default("pgup").describe("Scroll messages up by one page"),
messages_page_down: z.string().optional().default("pgdown").describe("Scroll messages down by one page"),
messages_half_page_up: z.string().optional().default("ctrl+alt+u").describe("Scroll messages up by half page"),
messages_half_page_down: z
.string()
.optional()
.default("ctrl+alt+d")
.describe("Scroll messages down by half page"),
messages_previous: z
.string()
.optional()
.describe("Navigate to previous message"),
messages_next: z.string().optional().describe("Navigate to next message"),
messages_first: z
.string()
.optional()
.describe("Navigate to first message"),
messages_last: z.string().optional().describe("Navigate to last message"),
app_exit: z.string().optional().describe("Exit the application"),
messages_previous: z.string().optional().default("ctrl+up").describe("Navigate to previous message"),
messages_next: z.string().optional().default("ctrl+down").describe("Navigate to next message"),
messages_first: z.string().optional().default("ctrl+g").describe("Navigate to first message"),
messages_last: z.string().optional().default("ctrl+alt+g").describe("Navigate to last message"),
messages_layout_toggle: z.string().optional().default("<leader>p").describe("Toggle layout"),
messages_copy: z.string().optional().default("<leader>y").describe("Copy message"),
messages_revert: z.string().optional().default("<leader>r").describe("Revert message"),
app_exit: z.string().optional().default("ctrl+c,<leader>q").describe("Exit the application"),
})
.strict()
.openapi({
@@ -136,33 +101,13 @@ export namespace Config {
})
export const Info = z
.object({
$schema: z
.string()
.optional()
.describe("JSON schema reference for configuration validation"),
theme: z
.string()
.optional()
.describe("Theme name to use for the interface"),
$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"),
autoshare: z
.boolean()
.optional()
.describe("Share newly created sessions automatically"),
autoupdate: z
.boolean()
.optional()
.describe("Automatically update to the latest version"),
disabled_providers: z
.array(z.string())
.optional()
.describe("Disable providers that are loaded automatically"),
model: z
.string()
.describe(
"Model to use in the format of provider/model, eg anthropic/claude-2",
)
.optional(),
autoshare: z.boolean().optional().describe("Share newly created sessions automatically"),
autoupdate: z.boolean().optional().describe("Automatically update to the latest version"),
disabled_providers: z.array(z.string()).optional().describe("Disable providers that are loaded automatically"),
model: z.string().describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(),
provider: z
.record(
ModelsDev.Provider.partial().extend({
@@ -172,10 +117,8 @@ export namespace Config {
)
.optional()
.describe("Custom provider configurations and model overrides"),
mcp: z
.record(z.string(), Mcp)
.optional()
.describe("MCP (Model Context Protocol) server configurations"),
mcp: z.record(z.string(), Mcp).optional().describe("MCP (Model Context Protocol) server configurations"),
instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"),
experimental: z
.object({
hook: z
@@ -223,10 +166,7 @@ export namespace Config {
if (provider && model) result.model = `${provider}/${model}`
result["$schema"] = "https://opencode.ai/config.json"
result = mergeDeep(result, rest)
await Bun.write(
path.join(Global.Path.config, "config.json"),
JSON.stringify(result, null, 2),
)
await Bun.write(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2))
await fs.unlink(path.join(Global.Path.config, "config"))
})
.catch(() => {})

View File

@@ -22,9 +22,7 @@ export namespace ConfigHooks {
command: item.command,
})
Bun.spawn({
cmd: item.command.map((x) =>
x.replace("$FILE", payload.properties.file),
),
cmd: item.command.map((x) => x.replace("$FILE", payload.properties.file)),
env: item.environment,
cwd: app.path.cwd,
stdout: "ignore",

View File

@@ -45,10 +45,7 @@ export namespace Fzf {
log.info("found", { filepath })
return { filepath }
}
filepath = path.join(
Global.Path.bin,
"fzf" + (process.platform === "win32" ? ".exe" : ""),
)
filepath = path.join(Global.Path.bin, "fzf" + (process.platform === "win32" ? ".exe" : ""))
const file = Bun.file(filepath)
if (!(await file.exists())) {
@@ -56,18 +53,15 @@ export namespace Fzf {
const arch = archMap[process.arch as keyof typeof archMap] ?? "amd64"
const config = PLATFORM[process.platform as keyof typeof PLATFORM]
if (!config)
throw new UnsupportedPlatformError({ platform: process.platform })
if (!config) throw new UnsupportedPlatformError({ platform: process.platform })
const version = VERSION
const platformName =
process.platform === "win32" ? "windows" : process.platform
const platformName = process.platform === "win32" ? "windows" : process.platform
const filename = `fzf-${version}-${platformName}_${arch}.${config.extension}`
const url = `https://github.com/junegunn/fzf/releases/download/v${version}/${filename}`
const response = await fetch(url)
if (!response.ok)
throw new DownloadFailedError({ url, status: response.status })
if (!response.ok) throw new DownloadFailedError({ url, status: response.status })
const buffer = await response.arrayBuffer()
const archivePath = path.join(Global.Path.bin, filename)
@@ -86,14 +80,11 @@ export namespace Fzf {
})
}
if (config.extension === "zip") {
const proc = Bun.spawn(
["unzip", "-j", archivePath, "fzf.exe", "-d", Global.Path.bin],
{
cwd: Global.Path.bin,
stderr: "pipe",
stdout: "ignore",
},
)
const proc = Bun.spawn(["unzip", "-j", archivePath, "fzf.exe", "-d", Global.Path.bin], {
cwd: Global.Path.bin,
stderr: "pipe",
stdout: "ignore",
})
await proc.exited
if (proc.exitCode !== 0)
throw new ExtractionFailedError({

View File

@@ -1,7 +1,16 @@
import { z } from "zod"
import { Bus } from "../bus"
import { $ } from "bun"
import { createPatch } from "diff"
import path from "path"
import * as git from "isomorphic-git"
import { App } from "../app/app"
import fs from "fs"
import { Log } from "../util/log"
export namespace File {
const log = Log.create({ service: "file" })
export const Event = {
Edited: Bus.event(
"file.edited",
@@ -10,4 +19,92 @@ export namespace File {
}),
),
}
export async function status() {
const app = App.info()
if (!app.git) return []
const diffOutput = await $`git diff --numstat HEAD`.cwd(app.path.cwd).quiet().nothrow().text()
const changedFiles = []
if (diffOutput.trim()) {
const lines = diffOutput.trim().split("\n")
for (const line of lines) {
const [added, removed, filepath] = line.split("\t")
changedFiles.push({
file: filepath,
added: added === "-" ? 0 : parseInt(added, 10),
removed: removed === "-" ? 0 : parseInt(removed, 10),
status: "modified",
})
}
}
const untrackedOutput = await $`git ls-files --others --exclude-standard`.cwd(app.path.cwd).quiet().nothrow().text()
if (untrackedOutput.trim()) {
const untrackedFiles = untrackedOutput.trim().split("\n")
for (const filepath of untrackedFiles) {
try {
const content = await Bun.file(path.join(app.path.root, filepath)).text()
const lines = content.split("\n").length
changedFiles.push({
file: filepath,
added: lines,
removed: 0,
status: "added",
})
} catch {
continue
}
}
}
// Get deleted files
const deletedOutput = await $`git diff --name-only --diff-filter=D HEAD`.cwd(app.path.cwd).quiet().nothrow().text()
if (deletedOutput.trim()) {
const deletedFiles = deletedOutput.trim().split("\n")
for (const filepath of deletedFiles) {
changedFiles.push({
file: filepath,
added: 0,
removed: 0, // Could get original line count but would require another git command
status: "deleted",
})
}
}
return changedFiles.map((x) => ({
...x,
file: path.relative(app.path.cwd, path.join(app.path.root, x.file)),
}))
}
export async function read(file: string) {
using _ = log.time("read", { file })
const app = App.info()
const full = path.join(app.path.cwd, file)
const content = await Bun.file(full)
.text()
.catch(() => "")
.then((x) => x.trim())
if (app.git) {
const rel = path.relative(app.path.root, full)
const diff = await git.status({
fs,
dir: app.path.root,
filepath: rel,
})
if (diff !== "unmodified") {
const original = await $`git show HEAD:${rel}`.cwd(app.path.root).quiet().nothrow().text()
const patch = createPatch(file, original, content, "old", "new", {
context: Infinity,
})
return { type: "patch", content: patch }
}
}
return { type: "raw", content }
}
}

View File

@@ -1,3 +1,4 @@
// Ripgrep utility functions
import path from "path"
import { Global } from "../global"
import fs from "fs/promises"
@@ -8,10 +9,91 @@ import { $ } from "bun"
import { Fzf } from "./fzf"
export namespace Ripgrep {
const Stats = z.object({
elapsed: z.object({
secs: z.number(),
nanos: z.number(),
human: z.string(),
}),
searches: z.number(),
searches_with_match: z.number(),
bytes_searched: z.number(),
bytes_printed: z.number(),
matched_lines: z.number(),
matches: z.number(),
})
const Begin = z.object({
type: z.literal("begin"),
data: z.object({
path: z.object({
text: z.string(),
}),
}),
})
export const Match = z.object({
type: z.literal("match"),
data: z.object({
path: z.object({
text: z.string(),
}),
lines: z.object({
text: z.string(),
}),
line_number: z.number(),
absolute_offset: z.number(),
submatches: z.array(
z.object({
match: z.object({
text: z.string(),
}),
start: z.number(),
end: z.number(),
}),
),
}),
})
const End = z.object({
type: z.literal("end"),
data: z.object({
path: z.object({
text: z.string(),
}),
binary_offset: z.number().nullable(),
stats: Stats,
}),
})
const Summary = z.object({
type: z.literal("summary"),
data: z.object({
elapsed_total: z.object({
human: z.string(),
nanos: z.number(),
secs: z.number(),
}),
stats: Stats,
}),
})
const Result = z.union([Begin, Match, End, Summary])
export type Result = z.infer<typeof Result>
export type Match = z.infer<typeof Match>
export type Begin = z.infer<typeof Begin>
export type End = z.infer<typeof End>
export type Summary = z.infer<typeof Summary>
const PLATFORM = {
darwin: { platform: "apple-darwin", extension: "tar.gz" },
linux: { platform: "unknown-linux-musl", extension: "tar.gz" },
win32: { platform: "pc-windows-msvc", extension: "zip" },
"arm64-darwin": { platform: "aarch64-apple-darwin", extension: "tar.gz" },
"arm64-linux": {
platform: "aarch64-unknown-linux-gnu",
extension: "tar.gz",
},
"x64-darwin": { platform: "x86_64-apple-darwin", extension: "tar.gz" },
"x64-linux": { platform: "x86_64-unknown-linux-musl", extension: "tar.gz" },
"x64-win32": { platform: "x86_64-pc-windows-msvc", extension: "zip" },
} as const
export const ExtractionFailedError = NamedError.create(
@@ -40,27 +122,20 @@ export namespace Ripgrep {
const state = lazy(async () => {
let filepath = Bun.which("rg")
if (filepath) return { filepath }
filepath = path.join(
Global.Path.bin,
"rg" + (process.platform === "win32" ? ".exe" : ""),
)
filepath = path.join(Global.Path.bin, "rg" + (process.platform === "win32" ? ".exe" : ""))
const file = Bun.file(filepath)
if (!(await file.exists())) {
const archMap = { x64: "x86_64", arm64: "aarch64" } as const
const arch = archMap[process.arch as keyof typeof archMap] ?? process.arch
const config = PLATFORM[process.platform as keyof typeof PLATFORM]
if (!config)
throw new UnsupportedPlatformError({ platform: process.platform })
const platformKey = `${process.arch}-${process.platform}` as keyof typeof PLATFORM
const config = PLATFORM[platformKey]
if (!config) throw new UnsupportedPlatformError({ platform: platformKey })
const version = "14.1.1"
const filename = `ripgrep-${version}-${arch}-${config.platform}.${config.extension}`
const filename = `ripgrep-${version}-${config.platform}.${config.extension}`
const url = `https://github.com/BurntSushi/ripgrep/releases/download/${version}/${filename}`
const response = await fetch(url)
if (!response.ok)
throw new DownloadFailedError({ url, status: response.status })
if (!response.ok) throw new DownloadFailedError({ url, status: response.status })
const buffer = await response.arrayBuffer()
const archivePath = path.join(Global.Path.bin, filename)
@@ -68,8 +143,8 @@ export namespace Ripgrep {
if (config.extension === "tar.gz") {
const args = ["tar", "-xzf", archivePath, "--strip-components=1"]
if (process.platform === "darwin") args.push("--include=*/rg")
if (process.platform === "linux") args.push("--wildcards", "*/rg")
if (platformKey.endsWith("-darwin")) args.push("--include=*/rg")
if (platformKey.endsWith("-linux")) args.push("--wildcards", "*/rg")
const proc = Bun.spawn(args, {
cwd: Global.Path.bin,
@@ -84,14 +159,11 @@ export namespace Ripgrep {
})
}
if (config.extension === "zip") {
const proc = Bun.spawn(
["unzip", "-j", archivePath, "*/rg.exe", "-d", Global.Path.bin],
{
cwd: Global.Path.bin,
stderr: "pipe",
stdout: "ignore",
},
)
const proc = Bun.spawn(["unzip", "-j", archivePath, "*/rg.exe", "-d", Global.Path.bin], {
cwd: Global.Path.bin,
stderr: "pipe",
stdout: "ignore",
})
await proc.exited
if (proc.exitCode !== 0)
throw new ExtractionFailedError({
@@ -100,7 +172,7 @@ export namespace Ripgrep {
})
}
await fs.unlink(archivePath)
if (process.platform !== "win32") await fs.chmod(filepath, 0o755)
if (!platformKey.endsWith("-win32")) await fs.chmod(filepath, 0o755)
}
return {
@@ -113,17 +185,16 @@ export namespace Ripgrep {
return filepath
}
export async function files(input: {
cwd: string
query?: string
glob?: string
limit?: number
}) {
const commands = [
`${await filepath()} --files --hidden --glob='!.git/*' ${input.glob ? `--glob='${input.glob}'` : ``}`,
]
if (input.query)
commands.push(`${await Fzf.filepath()} --filter=${input.query}`)
export async function files(input: { cwd: string; query?: string; glob?: string[]; limit?: number }) {
const commands = [`${$.escape(await filepath())} --files --follow --hidden --glob='!.git/*'`]
if (input.glob) {
for (const g of input.glob) {
commands[0] += ` --glob='${g}'`
}
}
if (input.query) commands.push(`${await Fzf.filepath()} --filter=${input.query}`)
if (input.limit) commands.push(`head -n ${input.limit}`)
const joined = commands.join(" | ")
const result = await $`${{ raw: joined }}`.cwd(input.cwd).nothrow().text()
@@ -229,4 +300,35 @@ export namespace Ripgrep {
return lines.join("\n")
}
export async function search(input: { cwd: string; pattern: string; glob?: string[]; limit?: number }) {
const args = [`${await filepath()}`, "--json", "--hidden", "--glob='!.git/*'"]
if (input.glob) {
for (const g of input.glob) {
args.push(`--glob=${g}`)
}
}
if (input.limit) {
args.push(`--max-count=${input.limit}`)
}
args.push(input.pattern)
const command = args.join(" ")
const result = await $`${{ raw: command }}`.cwd(input.cwd).quiet().nothrow()
if (result.exitCode !== 0) {
return []
}
const lines = result.text().trim().split("\n").filter(Boolean)
// Parse JSON lines from ripgrep output
return lines
.map((line) => JSON.parse(line))
.map((parsed) => Result.parse(parsed))
.filter((r) => r.type === "match")
.map((r) => r.data)
}
}

View File

@@ -1,6 +1,8 @@
import { App } from "../app/app"
import { Log } from "../util/log"
export namespace FileTime {
const log = Log.create({ service: "file.time" })
export const state = App.state("tool.filetimes", () => {
const read: {
[sessionID: string]: {
@@ -13,6 +15,7 @@ export namespace FileTime {
})
export function read(sessionID: string, file: string) {
log.info("read", { sessionID, file })
const { read } = state()
read[sessionID] = read[sessionID] || {}
read[sessionID][file] = new Date()
@@ -24,10 +27,7 @@ export namespace FileTime {
export async function assert(sessionID: string, filepath: string) {
const time = get(sessionID, filepath)
if (!time)
throw new Error(
`You must read the file ${filepath} before overwriting it. Use the Read tool first`,
)
if (!time) throw new Error(`You must read the file ${filepath} before overwriting it. Use the Read tool first`)
const stats = await Bun.file(filepath).stat()
if (stats.mtime.getTime() > time.getTime()) {
throw new Error(

View File

@@ -0,0 +1,52 @@
import { z } from "zod"
import { Bus } from "../bus"
import fs from "fs"
import { App } from "../app/app"
import { Log } from "../util/log"
import { Flag } from "../flag/flag"
export namespace FileWatcher {
const log = Log.create({ service: "file.watcher" })
export const Event = {
Updated: Bus.event(
"file.watcher.updated",
z.object({
file: z.string(),
event: z.union([z.literal("rename"), z.literal("change")]),
}),
),
}
const state = App.state(
"file.watcher",
() => {
const app = App.use()
if (!app.info.git) return {}
try {
const watcher = fs.watch(app.info.path.cwd, { recursive: true }, (event, file) => {
log.info("change", { file, event })
if (!file) return
// for some reason async local storage is lost here
// https://github.com/oven-sh/bun/issues/20754
App.provideExisting(app, async () => {
Bus.publish(Event.Updated, {
file,
event,
})
})
})
return { watcher }
} catch {
return {}
}
},
async (state) => {
state.watcher?.close()
},
)
export function init() {
if (Flag.OPENCODE_DISABLE_WATCHER) return
state()
}
}

View File

@@ -1,5 +1,6 @@
export namespace Flag {
export const OPENCODE_AUTO_SHARE = truthy("OPENCODE_AUTO_SHARE")
export const OPENCODE_DISABLE_WATCHER = truthy("OPENCODE_DISABLE_WATCHER")
function truthy(key: string) {
const value = process.env[key]?.toLowerCase()

View File

@@ -94,21 +94,7 @@ export const zig: Info = {
export const clang: Info = {
name: "clang-format",
command: ["clang-format", "-i", "$FILE"],
extensions: [
".c",
".cc",
".cpp",
".cxx",
".c++",
".h",
".hh",
".hpp",
".hxx",
".h++",
".ino",
".C",
".H",
],
extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"],
async enabled() {
return Bun.which("clang-format") !== null
},

View File

@@ -31,7 +31,7 @@ export namespace Format {
const result = []
for (const item of Object.values(Formatter)) {
if (!item.extensions.includes(ext)) continue
if (!isEnabled(item)) continue
if (!(await isEnabled(item))) continue
result.push(item)
}
return result

View File

@@ -26,11 +26,7 @@ export namespace Identifier {
return generateID(prefix, true, given)
}
function generateID(
prefix: keyof typeof prefixes,
descending: boolean,
given?: string,
): string {
function generateID(prefix: keyof typeof prefixes, descending: boolean, given?: string): string {
if (!given) {
return generateNewID(prefix, descending)
}
@@ -42,8 +38,7 @@ export namespace Identifier {
}
function randomBase62(length: number): string {
const chars =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
let result = ""
const bytes = randomBytes(length)
for (let i = 0; i < length; i++) {
@@ -52,10 +47,7 @@ export namespace Identifier {
return result
}
function generateNewID(
prefix: keyof typeof prefixes,
descending: boolean,
): string {
function generateNewID(prefix: keyof typeof prefixes, descending: boolean): string {
const currentTimestamp = Date.now()
if (currentTimestamp !== lastTimestamp) {
@@ -73,11 +65,6 @@ export namespace Identifier {
timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff))
}
return (
prefixes[prefix] +
"_" +
timeBytes.toString("hex") +
randomBase62(LENGTH - 12)
)
return prefixes[prefix] + "_" + timeBytes.toString("hex") + randomBase62(LENGTH - 12)
}
}

View File

@@ -55,10 +55,7 @@ const cli = yargs(hideBin(process.argv))
.command(ServeCommand)
.command(ModelsCommand)
.fail((msg) => {
if (
msg.startsWith("Unknown argument") ||
msg.startsWith("Not enough non-option arguments")
) {
if (msg.startsWith("Unknown argument") || msg.startsWith("Not enough non-option arguments")) {
cli.showHelp("log")
}
})
@@ -97,10 +94,7 @@ try {
Log.Default.error("fatal", data)
const formatted = FormatError(e)
if (formatted) UI.error(formatted)
if (formatted === undefined)
UI.error(
"Unexpected error, check log file at " + Log.file() + " for more details",
)
if (formatted === undefined) UI.error("Unexpected error, check log file at " + Log.file() + " for more details")
process.exitCode = 1
}

View File

@@ -135,8 +135,7 @@ export namespace Installation {
})
}
export const VERSION =
typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "dev"
export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "dev"
export async function latest() {
return fetch("https://api.github.com/repos/sst/opencode/releases/latest")

View File

@@ -1,9 +1,5 @@
import path from "path"
import {
createMessageConnection,
StreamMessageReader,
StreamMessageWriter,
} from "vscode-jsonrpc/node"
import { createMessageConnection, StreamMessageReader, StreamMessageWriter } from "vscode-jsonrpc/node"
import type { Diagnostic as VSCodeDiagnostic } from "vscode-languageserver-types"
import { App } from "../app/app"
import { Log } from "../util/log"
@@ -12,6 +8,7 @@ import { Bus } from "../bus"
import z from "zod"
import type { LSPServer } from "./server"
import { NamedError } from "../util/error"
import { withTimeout } from "../util/timeout"
export namespace LSPClient {
const log = Log.create({ service: "lsp.client" })
@@ -37,43 +34,54 @@ export namespace LSPClient {
),
}
export async function create(serverID: string, server: LSPServer.Handle) {
export async function create(input: { serverID: string; server: LSPServer.Handle; root: string }) {
const app = App.info()
log.info("starting client", { id: serverID })
const l = log.clone().tag("serverID", input.serverID)
l.info("starting client")
const connection = createMessageConnection(
new StreamMessageReader(server.process.stdout),
new StreamMessageWriter(server.process.stdin),
new StreamMessageReader(input.server.process.stdout),
new StreamMessageWriter(input.server.process.stdin),
)
const diagnostics = new Map<string, Diagnostic[]>()
connection.onNotification("textDocument/publishDiagnostics", (params) => {
const path = new URL(params.uri).pathname
log.info("textDocument/publishDiagnostics", {
l.info("textDocument/publishDiagnostics", {
path,
})
const exists = diagnostics.has(path)
diagnostics.set(path, params.diagnostics)
Bus.publish(Event.Diagnostics, { path, serverID })
if (!exists && input.serverID === "typescript") return
Bus.publish(Event.Diagnostics, { path, serverID: input.serverID })
})
connection.onRequest("window/workDoneProgress/create", (params) => {
l.info("window/workDoneProgress/create", params)
return null
})
connection.onRequest("workspace/configuration", async () => {
return [{}]
})
connection.listen()
log.info("sending initialize", { id: serverID })
await Promise.race([
l.info("sending initialize")
await withTimeout(
connection.sendRequest("initialize", {
processId: server.process.pid,
rootUri: "file://" + input.root,
processId: input.server.process.pid,
workspaceFolders: [
{
name: "workspace",
uri: "file://" + app.path.cwd,
uri: "file://" + input.root,
},
],
initializationOptions: {
...server.initialization,
...input.server.initialization,
},
capabilities: {
window: {
workDoneProgress: true,
},
workspace: {
configuration: true,
},
@@ -88,107 +96,99 @@ export namespace LSPClient {
},
},
}),
new Promise((_, reject) => {
setTimeout(() => {
reject(new InitializeError({ serverID }))
}, 5_000)
}),
])
5_000,
).catch((err) => {
l.error("initialize error", { error: err })
throw new InitializeError(
{ serverID: input.serverID },
{
cause: err,
},
)
})
await connection.sendNotification("initialized", {})
log.info("initialized")
const files: {
[path: string]: number
} = {}
const result = {
root: input.root,
get serverID() {
return serverID
return input.serverID
},
get connection() {
return connection
},
notify: {
async open(input: { path: string }) {
input.path = path.isAbsolute(input.path)
? input.path
: path.resolve(app.path.cwd, input.path)
input.path = path.isAbsolute(input.path) ? input.path : path.resolve(app.path.cwd, input.path)
const file = Bun.file(input.path)
const text = await file.text()
const version = files[input.path]
if (version === undefined) {
log.info("textDocument/didOpen", input)
if (version !== undefined) {
diagnostics.delete(input.path)
const extension = path.extname(input.path)
const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"
await connection.sendNotification("textDocument/didOpen", {
await connection.sendNotification("textDocument/didClose", {
textDocument: {
uri: `file://` + input.path,
languageId,
version: 0,
text,
},
})
files[input.path] = 0
return
}
log.info("textDocument/didChange", input)
log.info("textDocument/didOpen", input)
diagnostics.delete(input.path)
await connection.sendNotification("textDocument/didChange", {
const extension = path.extname(input.path)
const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"
await connection.sendNotification("textDocument/didOpen", {
textDocument: {
uri: `file://` + input.path,
version: ++files[input.path],
languageId,
version: 0,
text,
},
contentChanges: [
{
text,
},
],
})
files[input.path] = 0
return
},
},
get diagnostics() {
return diagnostics
},
async waitForDiagnostics(input: { path: string }) {
input.path = path.isAbsolute(input.path)
? input.path
: path.resolve(app.path.cwd, input.path)
input.path = path.isAbsolute(input.path) ? input.path : path.resolve(app.path.cwd, input.path)
log.info("waiting for diagnostics", input)
let unsub: () => void
let timeout: NodeJS.Timeout
return await Promise.race([
new Promise<void>(async (resolve) => {
return await withTimeout(
new Promise<void>((resolve) => {
unsub = Bus.subscribe(Event.Diagnostics, (event) => {
if (
event.properties.path === input.path &&
event.properties.serverID === result.serverID
) {
if (event.properties.path === input.path && event.properties.serverID === result.serverID) {
log.info("got diagnostics", input)
clearTimeout(timeout)
unsub?.()
resolve()
}
})
}),
new Promise<void>((resolve) => {
timeout = setTimeout(() => {
log.info("timed out refreshing diagnostics", input)
unsub?.()
resolve()
}, 5000)
}),
])
3000,
)
.catch(() => {})
.finally(() => {
unsub?.()
})
},
async shutdown() {
log.info("shutting down")
l.info("shutting down")
connection.end()
connection.dispose()
server.process.kill("SIGKILL")
input.server.process.kill()
l.info("shutdown")
},
}
if (input.server.onInitialized) {
await input.server.onInitialized(result)
}
l.info("initialized")
return result
}
}

View File

@@ -3,57 +3,92 @@ import { Log } from "../util/log"
import { LSPClient } from "./client"
import path from "path"
import { LSPServer } from "./server"
import { z } from "zod"
import { Filesystem } from "../util/filesystem"
export namespace LSP {
const log = Log.create({ service: "lsp" })
export const Symbol = z
.object({
name: z.string(),
kind: z.number(),
location: z.object({
uri: z.string(),
range: z.object({
start: z.object({
line: z.number(),
character: z.number(),
}),
end: z.object({
line: z.number(),
character: z.number(),
}),
}),
}),
})
.openapi({
ref: "LSP.Symbol",
})
export type Symbol = z.infer<typeof Symbol>
const state = App.state(
"lsp",
async () => {
async (app) => {
log.info("initializing")
const clients = new Map<string, LSPClient.Info>()
const skip = new Set<string>()
const clients: LSPClient.Info[] = []
for (const server of Object.values(LSPServer)) {
const roots = await server.roots(app)
for (const root of roots) {
if (!Filesystem.overlaps(app.path.cwd, root)) continue
log.info("", {
root,
serverID: server.id,
})
const handle = await server.spawn(App.info(), root)
if (!handle) break
const client = await LSPClient.create({
serverID: server.id,
server: handle,
root,
}).catch((err) => {
handle.process.kill()
log.error("", { error: err })
})
if (!client) break
clients.push(client)
}
}
log.info("initialized")
return {
clients,
skip,
}
},
async (state) => {
for (const client of state.clients.values()) {
for (const client of state.clients) {
await client.shutdown()
}
},
)
export async function init() {
return state()
}
export async function touchFile(input: string, waitForDiagnostics?: boolean) {
const extension = path.parse(input).ext
const s = await state()
const matches = Object.values(LSPServer).filter((x) =>
x.extensions.includes(extension),
)
for (const match of matches) {
if (s.skip.has(match.id)) continue
const existing = s.clients.get(match.id)
if (existing) continue
const handle = await match.spawn(App.info())
if (!handle) {
s.skip.add(match.id)
continue
}
const client = await LSPClient.create(match.id, handle).catch(() => {})
if (!client) {
s.skip.add(match.id)
continue
}
s.clients.set(match.id, client)
}
if (waitForDiagnostics) {
await run(async (client) => {
const wait = client.waitForDiagnostics({ path: input })
await client.notify.open({ path: input })
return wait
})
}
const matches = Object.values(LSPServer)
.filter((x) => x.extensions.includes(extension))
.map((x) => x.id)
await run(async (client) => {
if (!matches.includes(client.serverID)) return
const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: input }) : Promise.resolve()
await client.notify.open({ path: input })
return wait
})
}
export async function diagnostics() {
@@ -68,11 +103,7 @@ export namespace LSP {
return results
}
export async function hover(input: {
file: string
line: number
character: number
}) {
export async function hover(input: { file: string; line: number; character: number }) {
return run((client) => {
return client.connection.sendRequest("textDocument/hover", {
textDocument: {
@@ -86,10 +117,19 @@ export namespace LSP {
})
}
async function run<T>(
input: (client: LSPClient.Info) => Promise<T>,
): Promise<T[]> {
const clients = await state().then((x) => [...x.clients.values()])
export async function workspaceSymbol(query: string) {
return run((client) =>
client.connection
.sendRequest("workspace/symbol", {
query,
})
.then((result: any) => result.slice(0, 10))
.catch(() => []),
).then((result) => result.flat() as LSP.Symbol[])
}
async function run<T>(input: (client: LSPClient.Info) => Promise<T>): Promise<T[]> {
const clients = await state().then((x) => x.clients)
const tasks = clients.map((x) => input(x))
return Promise.all(tasks)
}

View File

@@ -94,4 +94,6 @@ export const LANGUAGE_EXTENSIONS: Record<string, string> = {
".yml": "yaml",
".mjs": "javascript",
".cjs": "javascript",
".zig": "zig",
".zon": "zig",
} as const

View File

@@ -4,6 +4,12 @@ import path from "path"
import { Global } from "../global"
import { Log } from "../util/log"
import { BunProc } from "../bun"
import { $ } from "bun"
import fs from "fs/promises"
import { unique } from "remeda"
import { Ripgrep } from "../file/ripgrep"
import type { LSPClient } from "./client"
import { withTimeout } from "../util/timeout"
export namespace LSPServer {
const log = Log.create({ service: "lsp.server" })
@@ -11,33 +17,45 @@ export namespace LSPServer {
export interface Handle {
process: ChildProcessWithoutNullStreams
initialization?: Record<string, any>
onInitialized?: (lsp: LSPClient.Info) => Promise<void>
}
type RootsFunction = (app: App.Info) => Promise<string[]>
const SimpleRoots = (patterns: string[]): RootsFunction => {
return async (app) => {
const glob = `**/*/{${patterns.join(",")}}`
const files = await Ripgrep.files({
glob: [glob],
cwd: app.path.root,
})
const dirs = files.map((file) => path.dirname(file))
return unique(dirs).map((dir) => path.join(app.path.root, dir))
}
}
export interface Info {
id: string
extensions: string[]
spawn(app: App.Info): Promise<Handle | undefined>
global?: boolean
roots: (app: App.Info) => Promise<string[]>
spawn(app: App.Info, root: string): Promise<Handle | undefined>
}
export const Typescript: Info = {
id: "typescript",
roots: SimpleRoots(["tsconfig.json", "jsconfig.json", "package.json"]),
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
async spawn(app) {
const tsserver = await Bun.resolve(
"typescript/lib/tsserver.js",
app.path.cwd,
).catch(() => {})
async spawn(app, root) {
const tsserver = await Bun.resolve("typescript/lib/tsserver.js", app.path.cwd).catch(() => {})
if (!tsserver) return
const proc = spawn(
BunProc.which(),
["x", "typescript-language-server", "--stdio"],
{
env: {
...process.env,
BUN_BE_BUN: "1",
},
const proc = spawn(BunProc.which(), ["x", "typescript-language-server", "--stdio"], {
cwd: root,
env: {
...process.env,
BUN_BE_BUN: "1",
},
)
})
return {
process: proc,
initialization: {
@@ -45,18 +63,37 @@ export namespace LSPServer {
path: tsserver,
},
},
// tsserver sucks and won't start processing codebase until you open a file
onInitialized: async (lsp) => {
const [hint] = await Ripgrep.files({
cwd: lsp.root,
glob: ["*.ts", "*.tsx", "*.js", "*.jsx", "*.mjs", "*.cjs", "*.mts", "*.cts"],
limit: 1,
})
const wait = new Promise<void>(async (resolve) => {
const notif = lsp.connection.onNotification("$/progress", (params) => {
if (params.value.kind !== "end") return
notif.dispose()
resolve()
})
await lsp.notify.open({ path: path.join(lsp.root, hint) })
})
await withTimeout(wait, 5_000)
},
}
},
}
export const Gopls: Info = {
id: "golang",
roots: SimpleRoots(["go.mod", "go.sum"]),
extensions: [".go"],
async spawn() {
async spawn(_, root) {
let bin = Bun.which("gopls", {
PATH: process.env["PATH"] + ":" + Global.Path.bin,
})
if (!bin) {
if (!Bun.which("go")) return
log.info("installing gopls")
const proc = Bun.spawn({
cmd: ["go", "install", "golang.org/x/tools/gopls@latest"],
@@ -70,24 +107,24 @@ export namespace LSPServer {
log.error("Failed to install gopls")
return
}
bin = path.join(
Global.Path.bin,
"gopls" + (process.platform === "win32" ? ".exe" : ""),
)
bin = path.join(Global.Path.bin, "gopls" + (process.platform === "win32" ? ".exe" : ""))
log.info(`installed gopls`, {
bin,
})
}
return {
process: spawn(bin!),
process: spawn(bin!, {
cwd: root,
}),
}
},
}
export const RubyLsp: Info = {
id: "ruby-lsp",
roots: SimpleRoots(["Gemfile"]),
extensions: [".rb", ".rake", ".gemspec", ".ru"],
async spawn() {
async spawn(_, root) {
let bin = Bun.which("ruby-lsp", {
PATH: process.env["PATH"] + ":" + Global.Path.bin,
})
@@ -110,16 +147,95 @@ export namespace LSPServer {
log.error("Failed to install ruby-lsp")
return
}
bin = path.join(
Global.Path.bin,
"ruby-lsp" + (process.platform === "win32" ? ".exe" : ""),
)
bin = path.join(Global.Path.bin, "ruby-lsp" + (process.platform === "win32" ? ".exe" : ""))
log.info(`installed ruby-lsp`, {
bin,
})
}
return {
process: spawn(bin!, ["--stdio"]),
process: spawn(bin!, ["--stdio"], {
cwd: root,
}),
}
},
}
export const Pyright: Info = {
id: "pyright",
extensions: [".py", ".pyi"],
roots: SimpleRoots([
"pyproject.toml",
"setup.py",
"setup.cfg",
"requirements.txt",
"Pipfile",
"pyrightconfig.json",
]),
async spawn(_, root) {
const proc = spawn(BunProc.which(), ["x", "pyright-langserver", "--stdio"], {
cwd: root,
env: {
...process.env,
BUN_BE_BUN: "1",
},
})
return {
process: proc,
}
},
}
export const ElixirLS: Info = {
id: "elixir-ls",
extensions: [".ex", ".exs"],
roots: SimpleRoots(["mix.exs", "mix.lock"]),
async spawn(_, root) {
let binary = Bun.which("elixir-ls")
if (!binary) {
const elixirLsPath = path.join(Global.Path.bin, "elixir-ls")
binary = path.join(
Global.Path.bin,
"elixir-ls-master",
"release",
process.platform === "win32" ? "language_server.bar" : "language_server.sh",
)
if (!(await Bun.file(binary).exists())) {
const elixir = Bun.which("elixir")
if (!elixir) {
log.error("elixir is required to run elixir-ls")
return
}
log.info("downloading elixir-ls from GitHub releases")
const response = await fetch("https://github.com/elixir-lsp/elixir-ls/archive/refs/heads/master.zip")
if (!response.ok) return
const zipPath = path.join(Global.Path.bin, "elixir-ls.zip")
await Bun.file(zipPath).write(response)
await $`unzip -o -q ${zipPath}`.cwd(Global.Path.bin).nothrow()
await fs.rm(zipPath, {
force: true,
recursive: true,
})
await $`mix deps.get && mix compile && mix elixir_ls.release2 -o release`
.quiet()
.cwd(path.join(Global.Path.bin, "elixir-ls-master"))
.env({ MIX_ENV: "prod", ...process.env })
log.info(`installed elixir-ls`, {
path: elixirLsPath,
})
}
}
return {
process: spawn(binary, {
cwd: root,
}),
}
},
}

View File

@@ -10,7 +10,9 @@ export namespace ModelsDev {
export const Model = z
.object({
id: z.string(),
name: z.string(),
release_date: z.string(),
attachment: z.boolean(),
reasoning: z.boolean(),
temperature: z.boolean(),
@@ -25,7 +27,6 @@ export namespace ModelsDev {
context: z.number(),
output: z.number(),
}),
id: z.string(),
options: z.record(z.any()),
})
.openapi({

View File

@@ -91,19 +91,34 @@ export namespace Provider {
if (!info || info.type !== "oauth") return
if (!info.access || info.expires < Date.now()) {
const tokens = await copilot.access(info.refresh)
if (!tokens)
throw new Error("GitHub Copilot authentication expired")
if (!tokens) throw new Error("GitHub Copilot authentication expired")
await Auth.set("github-copilot", {
type: "oauth",
...tokens,
})
info.access = tokens.access
}
const headers = {
let isAgentCall = false
let isVisionRequest = false
try {
const body = typeof init.body === "string" ? JSON.parse(init.body) : init.body
if (body?.messages) {
isAgentCall = body.messages.some((msg: any) => msg.role && ["tool", "assistant"].includes(msg.role))
isVisionRequest = body.messages.some(
(msg: any) =>
Array.isArray(msg.content) && msg.content.some((part: any) => part.type === "image_url"),
)
}
} catch {}
const headers: Record<string, string> = {
...init.headers,
...copilot.HEADERS,
Authorization: `Bearer ${info.access}`,
"Openai-Intent": "conversation-edits",
"X-Initiator": isAgentCall ? "agent" : "user",
}
if (isVisionRequest) {
headers["Copilot-Vision-Request"] = "true"
}
delete headers["x-api-key"]
return fetch(input, {
@@ -124,14 +139,11 @@ export namespace Provider {
}
},
"amazon-bedrock": async () => {
if (!process.env["AWS_PROFILE"] && !process.env["AWS_ACCESS_KEY_ID"])
return { autoload: false }
if (!process.env["AWS_PROFILE"] && !process.env["AWS_ACCESS_KEY_ID"]) return { autoload: false }
const region = process.env["AWS_REGION"] ?? "us-east-1"
const { fromNodeProviderChain } = await import(
await BunProc.install("@aws-sdk/credential-providers")
)
const { fromNodeProviderChain } = await import(await BunProc.install("@aws-sdk/credential-providers"))
return {
autoload: true,
options: {
@@ -143,9 +155,7 @@ export namespace Provider {
switch (regionPrefix) {
case "us": {
const modelRequiresPrefix = ["claude", "deepseek"].some((m) =>
modelID.includes(m),
)
const modelRequiresPrefix = ["claude", "deepseek"].some((m) => modelID.includes(m))
if (modelRequiresPrefix) {
modelID = `${regionPrefix}.${modelID}`
}
@@ -160,25 +170,18 @@ export namespace Provider {
"eu-south-1",
"eu-south-2",
].some((r) => region.includes(r))
const modelRequiresPrefix = [
"claude",
"nova-lite",
"nova-micro",
"llama3",
"pixtral",
].some((m) => modelID.includes(m))
const modelRequiresPrefix = ["claude", "nova-lite", "nova-micro", "llama3", "pixtral"].some((m) =>
modelID.includes(m),
)
if (regionRequiresPrefix && modelRequiresPrefix) {
modelID = `${regionPrefix}.${modelID}`
}
break
}
case "ap": {
const modelRequiresPrefix = [
"claude",
"nova-lite",
"nova-micro",
"nova-pro",
].some((m) => modelID.includes(m))
const modelRequiresPrefix = ["claude", "nova-lite", "nova-micro", "nova-pro"].some((m) =>
modelID.includes(m),
)
if (modelRequiresPrefix) {
regionPrefix = "apac"
modelID = `${regionPrefix}.${modelID}`
@@ -191,6 +194,17 @@ export namespace Provider {
},
}
},
openrouter: async () => {
return {
autoload: false,
options: {
headers: {
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
},
},
}
},
}
const state = App.state("provider", async () => {
@@ -205,10 +219,7 @@ export namespace Provider {
options: Record<string, any>
}
} = {}
const models = new Map<
string,
{ info: ModelsDev.Model; language: LanguageModel }
>()
const models = new Map<string, { info: ModelsDev.Model; language: LanguageModel }>()
const sdk = new Map<string, SDK>()
log.info("init")
@@ -246,6 +257,7 @@ export namespace Provider {
npm: provider.npm ?? existing?.npm,
name: provider.name ?? existing?.name ?? providerID,
env: provider.env ?? existing?.env ?? [],
api: provider.api ?? existing?.api,
models: existing?.models ?? {},
}
@@ -254,6 +266,7 @@ export namespace Provider {
const parsedModel: ModelsDev.Model = {
id: modelID,
name: model.name ?? existing?.name ?? modelID,
release_date: model.release_date ?? existing?.release_date,
attachment: model.attachment ?? existing?.attachment ?? false,
reasoning: model.reasoning ?? existing?.reasoning ?? false,
temperature: model.temperature ?? existing?.temperature ?? false,
@@ -281,15 +294,18 @@ export namespace Provider {
database[providerID] = parsed
}
const disabled = await Config.get().then(
(cfg) => new Set(cfg.disabled_providers ?? []),
)
const disabled = await Config.get().then((cfg) => new Set(cfg.disabled_providers ?? []))
// load env
for (const [providerID, provider] of Object.entries(database)) {
if (disabled.has(providerID)) continue
if (provider.env.some((item) => process.env[item])) {
mergeProvider(providerID, {}, "env")
}
const apiKey = provider.env.map((item) => process.env[item]).at(0)
if (!apiKey) continue
mergeProvider(
providerID,
// only include apiKey if there's only one potential option
provider.env.length === 1 ? { apiKey } : {},
"env",
)
}
// load apikeys
@@ -305,12 +321,7 @@ export namespace Provider {
if (disabled.has(providerID)) continue
const result = await fn(database[providerID])
if (result && (result.autoload || providers[providerID])) {
mergeProvider(
providerID,
result.options ?? {},
"custom",
result.getModel,
)
mergeProvider(providerID, result.options ?? {}, "custom", result.getModel)
}
}
@@ -347,7 +358,7 @@ export namespace Provider {
const existing = s.sdk.get(provider.id)
if (existing) return existing
const pkg = provider.npm ?? provider.id
const mod = await import(await BunProc.install(pkg, "latest"))
const mod = await import(await BunProc.install(pkg, "beta"))
const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!]
const loaded = fn(s.providers[provider.id]?.options)
s.sdk.set(provider.id, loaded)
@@ -374,9 +385,7 @@ export namespace Provider {
const sdk = await getSDK(provider.info)
try {
const language = provider.getModel
? await provider.getModel(sdk, modelID)
: sdk.languageModel(modelID)
const language = provider.getModel ? await provider.getModel(sdk, modelID) : sdk.languageModel(modelID)
log.info("found", { providerID, modelID })
s.models.set(key, {
info,
@@ -403,10 +412,7 @@ export namespace Provider {
export function sort(models: ModelsDev.Model[]) {
return sortBy(
models,
[
(model) => priority.findIndex((filter) => model.id.includes(filter)),
"desc",
],
[(model) => priority.findIndex((filter) => model.id.includes(filter)), "desc"],
[(model) => (model.id.includes("latest") ? 0 : 1), "asc"],
[(model) => model.id, "desc"],
)
@@ -417,11 +423,7 @@ export namespace Provider {
if (cfg.model) return parseModel(cfg.model)
const provider = await list()
.then((val) => Object.values(val))
.then((x) =>
x.find(
(p) => !cfg.provider || Object.keys(cfg.provider).includes(p.info.id),
),
)
.then((x) => x.find((p) => !cfg.provider || Object.keys(cfg.provider).includes(p.info.id)))
if (!provider) throw new Error("no providers found")
const [model] = sort(Object.values(provider.info.models))
if (!model) throw new Error("no models found")
@@ -450,7 +452,6 @@ export namespace Provider {
// LspHoverTool,
PatchTool,
ReadTool,
EditTool,
// MultiEditTool,
WriteTool,
TodoWriteTool,
@@ -505,9 +506,11 @@ export namespace Provider {
if (schema instanceof z.ZodUnion) {
return z.union(
schema.options.map((option: z.ZodTypeAny) =>
optionalToNullable(option),
) as [z.ZodTypeAny, z.ZodTypeAny, ...z.ZodTypeAny[]],
schema.options.map((option: z.ZodTypeAny) => optionalToNullable(option)) as [
z.ZodTypeAny,
z.ZodTypeAny,
...z.ZodTypeAny[],
],
)
}

View File

@@ -1,22 +1,21 @@
import type { LanguageModelV1Prompt } from "ai"
import type { ModelMessage } from "ai"
import { unique } from "remeda"
export namespace ProviderTransform {
export function message(
msgs: LanguageModelV1Prompt,
providerID: string,
modelID: string,
) {
export function message(msgs: ModelMessage[], providerID: string, modelID: string) {
if (providerID === "anthropic" || modelID.includes("anthropic")) {
const system = msgs.filter((msg) => msg.role === "system").slice(0, 2)
const final = msgs.filter((msg) => msg.role !== "system").slice(-2)
for (const msg of unique([...system, ...final])) {
msg.providerMetadata = {
...msg.providerMetadata,
msg.providerOptions = {
...msg.providerOptions,
anthropic: {
cacheControl: { type: "ephemeral" },
},
openaiCompatible: {
cache_control: { type: "ephemeral" },
},
}
}
}
@@ -25,8 +24,8 @@ export namespace ProviderTransform {
const final = msgs.filter((msg) => msg.role !== "system").slice(-2)
for (const msg of unique([...system, ...final])) {
msg.providerMetadata = {
...msg.providerMetadata,
msg.providerOptions = {
...msg.providerOptions,
bedrock: {
cachePoint: { type: "ephemeral" },
},

View File

@@ -6,7 +6,6 @@ import { streamSSE } from "hono/streaming"
import { Session } from "../session"
import { resolver, validator as zValidator } from "hono-openapi/zod"
import { z } from "zod"
import { Message } from "../session/message"
import { Provider } from "../provider/provider"
import { App } from "../app/app"
import { mapValues } from "remeda"
@@ -14,6 +13,9 @@ import { NamedError } from "../util/error"
import { ModelsDev } from "../provider/models"
import { Ripgrep } from "../file/ripgrep"
import { Config } from "../config/config"
import { File } from "../file"
import { LSP } from "../lsp"
import { MessageV2 } from "../session/message-v2"
const ERRORS = {
400: {
@@ -49,12 +51,9 @@ export namespace Server {
status: 400,
})
}
return c.json(
new NamedError.Unknown({ message: err.toString() }).toObject(),
{
status: 400,
},
)
return c.json(new NamedError.Unknown({ message: err.toString() }).toObject(), {
status: 400,
})
})
.use(async (c, next) => {
log.info("request", {
@@ -73,7 +72,7 @@ export namespace Server {
documentation: {
info: {
title: "opencode",
version: "0.0.2",
version: "0.0.3",
description: "opencode api",
},
openapi: "3.0.0",
@@ -405,7 +404,7 @@ export namespace Server {
description: "List of messages",
content: {
"application/json": {
schema: resolver(Message.Info.array()),
schema: resolver(MessageV2.Info.array()),
},
},
},
@@ -431,7 +430,7 @@ export namespace Server {
description: "Created message",
content: {
"application/json": {
schema: resolver(Message.Info),
schema: resolver(MessageV2.Assistant),
},
},
},
@@ -448,7 +447,7 @@ export namespace Server {
z.object({
providerID: z.string(),
modelID: z.string(),
parts: Message.MessagePart.array(),
parts: MessageV2.UserPart.array(),
}),
),
async (c) => {
@@ -479,25 +478,52 @@ export namespace Server {
},
}),
async (c) => {
const providers = await Provider.list().then((x) =>
mapValues(x, (item) => item.info),
)
const providers = await Provider.list().then((x) => mapValues(x, (item) => item.info))
return c.json({
providers: Object.values(providers),
default: mapValues(
providers,
(item) => Provider.sort(Object.values(item.models))[0].id,
),
default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
})
},
)
.get(
"/file",
"/find",
describeRoute({
description: "Search for files",
description: "Find text in files",
responses: {
200: {
description: "Search for files",
description: "Matches",
content: {
"application/json": {
schema: resolver(Ripgrep.Match.shape.data.array()),
},
},
},
},
}),
zValidator(
"query",
z.object({
pattern: z.string(),
}),
),
async (c) => {
const app = App.info()
const pattern = c.req.valid("query").pattern
const result = await Ripgrep.search({
cwd: app.path.cwd,
pattern,
limit: 10,
})
return c.json(result)
},
)
.get(
"/find/file",
describeRoute({
description: "Find files",
responses: {
200: {
description: "File paths",
content: {
"application/json": {
schema: resolver(z.string().array()),
@@ -523,6 +549,98 @@ export namespace Server {
return c.json(result)
},
)
.get(
"/find/symbol",
describeRoute({
description: "Find workspace symbols",
responses: {
200: {
description: "Symbols",
content: {
"application/json": {
schema: resolver(LSP.Symbol.array()),
},
},
},
},
}),
zValidator(
"query",
z.object({
query: z.string(),
}),
),
async (c) => {
const query = c.req.valid("query").query
const result = await LSP.workspaceSymbol(query)
return c.json(result)
},
)
.get(
"/file",
describeRoute({
description: "Read a file",
responses: {
200: {
description: "File content",
content: {
"application/json": {
schema: resolver(
z.object({
type: z.enum(["raw", "patch"]),
content: z.string(),
}),
),
},
},
},
},
}),
zValidator(
"query",
z.object({
path: z.string(),
}),
),
async (c) => {
const path = c.req.valid("query").path
const content = await File.read(path)
log.info("read file", {
path,
content: content.content,
})
return c.json(content)
},
)
.get(
"/file/status",
describeRoute({
description: "Get file status",
responses: {
200: {
description: "File status",
content: {
"application/json": {
schema: resolver(
z
.object({
file: z.string(),
added: z.number().int(),
removed: z.number().int(),
status: z.enum(["added", "deleted", "modified"]),
})
.array(),
),
},
},
},
},
}),
async (c) => {
const content = await File.status()
return c.json(content)
},
)
return result
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,404 @@
import z from "zod"
import { Bus } from "../bus"
import { Provider } from "../provider/provider"
import { NamedError } from "../util/error"
import { Message } from "./message"
import { convertToModelMessages, type ModelMessage, type UIMessage } from "ai"
export namespace MessageV2 {
export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({}))
export const AbortedError = NamedError.create("MessageAbortedError", z.object({}))
export const ToolStatePending = z
.object({
status: z.literal("pending"),
})
.openapi({
ref: "ToolStatePending",
})
export type ToolStatePending = z.infer<typeof ToolStatePending>
export const ToolStateRunning = z
.object({
status: z.literal("running"),
input: z.any(),
title: z.string().optional(),
metadata: z.record(z.any()).optional(),
time: z.object({
start: z.number(),
}),
})
.openapi({
ref: "ToolStateRunning",
})
export type ToolStateRunning = z.infer<typeof ToolStateRunning>
export const ToolStateCompleted = z
.object({
status: z.literal("completed"),
input: z.record(z.any()),
output: z.string(),
title: z.string(),
metadata: z.record(z.any()),
time: z.object({
start: z.number(),
end: z.number(),
}),
})
.openapi({
ref: "ToolStateCompleted",
})
export type ToolStateCompleted = z.infer<typeof ToolStateCompleted>
export const ToolStateError = z
.object({
status: z.literal("error"),
input: z.record(z.any()),
error: z.string(),
time: z.object({
start: z.number(),
end: z.number(),
}),
})
.openapi({
ref: "ToolStateError",
})
export type ToolStateError = z.infer<typeof ToolStateError>
export const ToolState = z
.discriminatedUnion("status", [ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError])
.openapi({
ref: "ToolState",
})
export const TextPart = z
.object({
type: z.literal("text"),
text: z.string(),
})
.openapi({
ref: "TextPart",
})
export type TextPart = z.infer<typeof TextPart>
export const ToolPart = z
.object({
type: z.literal("tool"),
id: z.string(),
tool: z.string(),
state: ToolState,
})
.openapi({
ref: "ToolPart",
})
export type ToolPart = z.infer<typeof ToolPart>
export const FilePart = z
.object({
type: z.literal("file"),
mime: z.string(),
filename: z.string().optional(),
url: z.string(),
})
.openapi({
ref: "FilePart",
})
export type FilePart = z.infer<typeof FilePart>
export const StepStartPart = z
.object({
type: z.literal("step-start"),
})
.openapi({
ref: "StepStartPart",
})
export type StepStartPart = z.infer<typeof StepStartPart>
const Base = z.object({
id: z.string(),
sessionID: z.string(),
})
export const UserPart = z.discriminatedUnion("type", [TextPart, FilePart]).openapi({
ref: "UserMessagePart",
})
export type UserPart = z.infer<typeof UserPart>
export const User = Base.extend({
role: z.literal("user"),
parts: z.array(UserPart),
time: z.object({
created: z.number(),
}),
}).openapi({
ref: "UserMessage",
})
export type User = z.infer<typeof User>
export const AssistantPart = z.discriminatedUnion("type", [TextPart, ToolPart, StepStartPart]).openapi({
ref: "AssistantMessagePart",
})
export type AssistantPart = z.infer<typeof AssistantPart>
export const Assistant = Base.extend({
role: z.literal("assistant"),
parts: z.array(AssistantPart),
time: z.object({
created: z.number(),
completed: z.number().optional(),
}),
error: z
.discriminatedUnion("name", [
Provider.AuthError.Schema,
NamedError.Unknown.Schema,
OutputLengthError.Schema,
AbortedError.Schema,
])
.optional(),
system: z.string().array(),
modelID: z.string(),
providerID: z.string(),
path: z.object({
cwd: z.string(),
root: z.string(),
}),
cost: z.number(),
summary: z.boolean().optional(),
tokens: z.object({
input: z.number(),
output: z.number(),
reasoning: z.number(),
cache: z.object({
read: z.number(),
write: z.number(),
}),
}),
}).openapi({
ref: "AssistantMessage",
})
export type Assistant = z.infer<typeof Assistant>
export const Info = z.discriminatedUnion("role", [User, Assistant]).openapi({
ref: "Message",
})
export type Info = z.infer<typeof Info>
export const Event = {
Updated: Bus.event(
"message.updated",
z.object({
info: Info,
}),
),
Removed: Bus.event(
"message.removed",
z.object({
sessionID: z.string(),
messageID: z.string(),
}),
),
PartUpdated: Bus.event(
"message.part.updated",
z.object({
part: AssistantPart,
sessionID: z.string(),
messageID: z.string(),
}),
),
}
export function fromV1(v1: Message.Info) {
if (v1.role === "assistant") {
const result: Assistant = {
id: v1.id,
sessionID: v1.metadata.sessionID,
role: "assistant",
time: {
created: v1.metadata.time.created,
completed: v1.metadata.time.completed,
},
cost: v1.metadata.assistant!.cost,
path: v1.metadata.assistant!.path,
summary: v1.metadata.assistant!.summary,
tokens: v1.metadata.assistant!.tokens,
modelID: v1.metadata.assistant!.modelID,
providerID: v1.metadata.assistant!.providerID,
system: v1.metadata.assistant!.system,
error: v1.metadata.error,
parts: v1.parts.flatMap((part): AssistantPart[] => {
if (part.type === "text") {
return [
{
type: "text",
text: part.text,
},
]
}
if (part.type === "step-start") {
return [
{
type: "step-start",
},
]
}
if (part.type === "tool-invocation") {
return [
{
type: "tool",
id: part.toolInvocation.toolCallId,
tool: part.toolInvocation.toolName,
state: (() => {
if (part.toolInvocation.state === "partial-call") {
return {
status: "pending",
}
}
const { title, time, ...metadata } = v1.metadata.tool[part.toolInvocation.toolCallId] ?? {}
if (part.toolInvocation.state === "call") {
return {
status: "running",
input: part.toolInvocation.args,
time: {
start: time?.start,
},
}
}
if (part.toolInvocation.state === "result") {
return {
status: "completed",
input: part.toolInvocation.args,
output: part.toolInvocation.result,
title,
time,
metadata,
}
}
throw new Error("unknown tool invocation state")
})(),
},
]
}
return []
}),
}
return result
}
if (v1.role === "user") {
const result: User = {
id: v1.id,
sessionID: v1.metadata.sessionID,
role: "user",
time: {
created: v1.metadata.time.created,
},
parts: v1.parts.flatMap((part): UserPart[] => {
if (part.type === "text") {
return [
{
type: "text",
text: part.text,
},
]
}
if (part.type === "file") {
return [
{
type: "file",
mime: part.mediaType,
filename: part.filename,
url: part.url,
},
]
}
return []
}),
}
return result
}
}
export function toModelMessage(input: Info[]): ModelMessage[] {
const result: UIMessage[] = []
for (const msg of input) {
if (msg.parts.length === 0) continue
if (msg.role === "user") {
result.push({
id: msg.id,
role: "user",
parts: msg.parts.flatMap((part): UIMessage["parts"] => {
if (part.type === "text")
return [
{
type: "text",
text: part.text,
},
]
if (part.type === "file")
return [
{
type: "file",
url: part.url,
mediaType: part.mime,
filename: part.filename,
},
]
return []
}),
})
}
if (msg.role === "assistant") {
result.push({
id: msg.id,
role: "assistant",
parts: msg.parts.flatMap((part): UIMessage["parts"] => {
if (part.type === "text")
return [
{
type: "text",
text: part.text,
},
]
if (part.type === "step-start")
return [
{
type: "step-start",
},
]
if (part.type === "tool") {
if (part.state.status === "completed")
return [
{
type: ("tool-" + part.tool) as `tool-${string}`,
state: "output-available",
toolCallId: part.id,
input: part.state.input,
output: part.state.output,
},
]
if (part.state.status === "error")
return [
{
type: ("tool-" + part.tool) as `tool-${string}`,
state: "output-error",
toolCallId: part.id,
input: part.state.input,
errorText: part.state.error,
},
]
}
return []
}),
})
}
}
return convertToModelMessages(result)
}
}

View File

@@ -1,13 +1,9 @@
import z from "zod"
import { Bus } from "../bus"
import { Provider } from "../provider/provider"
import { NamedError } from "../util/error"
export namespace Message {
export const OutputLengthError = NamedError.create(
"MessageOutputLengthError",
z.object({}),
)
export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({}))
export const ToolCall = z
.object({
@@ -49,11 +45,9 @@ export namespace Message {
})
export type ToolResult = z.infer<typeof ToolResult>
export const ToolInvocation = z
.discriminatedUnion("state", [ToolCall, ToolPartialCall, ToolResult])
.openapi({
ref: "ToolInvocation",
})
export const ToolInvocation = z.discriminatedUnion("state", [ToolCall, ToolPartialCall, ToolResult]).openapi({
ref: "ToolInvocation",
})
export type ToolInvocation = z.infer<typeof ToolInvocation>
export const TextPart = z
@@ -122,14 +116,7 @@ export namespace Message {
export type StepStartPart = z.infer<typeof StepStartPart>
export const MessagePart = z
.discriminatedUnion("type", [
TextPart,
ReasoningPart,
ToolInvocationPart,
SourceUrlPart,
FilePart,
StepStartPart,
])
.discriminatedUnion("type", [TextPart, ReasoningPart, ToolInvocationPart, SourceUrlPart, FilePart, StepStartPart])
.openapi({
ref: "MessagePart",
})
@@ -159,6 +146,7 @@ export namespace Message {
z
.object({
title: z.string(),
snapshot: z.string().optional(),
time: z.object({
start: z.number(),
end: z.number(),
@@ -188,6 +176,7 @@ export namespace Message {
}),
})
.optional(),
snapshot: z.string().optional(),
})
.openapi({ ref: "MessageMetadata" }),
})
@@ -195,21 +184,4 @@ export namespace Message {
ref: "Message",
})
export type Info = z.infer<typeof Info>
export const Event = {
Updated: Bus.event(
"message.updated",
z.object({
info: Info,
}),
),
PartUpdated: Bus.event(
"message.part.updated",
z.object({
part: MessagePart,
sessionID: z.string(),
messageID: z.string(),
}),
),
}
}

View File

@@ -134,7 +134,7 @@ The user will primarily request you perform software engineering tasks. This inc
- Use the available search tools to understand the codebase and the user's query. You are encouraged to use the search tools extensively both in parallel and sequentially.
- Implement the solution using all tools available to you
- Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach.
- VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (eg. npm run lint, npm run typecheck, ruff, etc.) with Bash if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to CLAUDE.md so that you will know to run it next time.
- VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (eg. npm run lint, npm run typecheck, ruff, etc.) with Bash if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to AGENTS.md so that you will know to run it next time.
NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
- Tool results and user messages may include <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result.

View File

@@ -2,6 +2,7 @@ import { App } from "../app/app"
import { Ripgrep } from "../file/ripgrep"
import { Global } from "../global"
import { Filesystem } from "../util/filesystem"
import { Config } from "../config/config"
import path from "path"
import os from "os"
@@ -55,8 +56,10 @@ export namespace SystemPrompt {
"CLAUDE.md",
"CONTEXT.md", // deprecated
]
export async function custom() {
const { cwd, root } = App.info().path
const config = await Config.get()
const found = []
for (const item of CUSTOM_FILES) {
const matches = await Filesystem.findUp(item, cwd, root)
@@ -72,6 +75,18 @@ export namespace SystemPrompt {
.text()
.catch(() => ""),
)
if (config.instructions) {
for (const instruction of config.instructions) {
try {
const matches = await Filesystem.globUp(instruction, cwd, root)
found.push(...matches.map((x) => Bun.file(x).text()))
} catch {
continue // Skip invalid glob patterns
}
}
}
return Promise.all(found).then((result) => result.filter(Boolean))
}

View File

@@ -53,9 +53,7 @@ export namespace Share {
export const URL =
process.env["OPENCODE_API"] ??
(Installation.isSnapshot() || Installation.isDev()
? "https://api.dev.opencode.ai"
: "https://api.opencode.ai")
(Installation.isSnapshot() || Installation.isDev() ? "https://api.dev.opencode.ai" : "https://api.opencode.ai")
export async function create(sessionID: string) {
return fetch(`${URL}/share_create`, {
@@ -66,10 +64,10 @@ export namespace Share {
.then((x) => x as { url: string; secret: string })
}
export async function remove(id: string) {
export async function remove(sessionID: string, secret: string) {
return fetch(`${URL}/share_delete`, {
method: "POST",
body: JSON.stringify({ id }),
body: JSON.stringify({ sessionID, secret }),
}).then((x) => x.json())
}
}

View File

@@ -0,0 +1,65 @@
import { App } from "../app/app"
import { $ } from "bun"
import path from "path"
import fs from "fs/promises"
import { Ripgrep } from "../file/ripgrep"
import { Log } from "../util/log"
export namespace Snapshot {
const log = Log.create({ service: "snapshot" })
export async function create(sessionID: string) {
return
log.info("creating snapshot")
const app = App.info()
const git = gitdir(sessionID)
// not a git repo, check if too big to snapshot
if (!app.git) {
const files = await Ripgrep.files({
cwd: app.path.cwd,
limit: 1000,
})
log.info("found files", { count: files.length })
if (files.length > 1000) return
}
if (await fs.mkdir(git, { recursive: true })) {
await $`git init`
.env({
...process.env,
GIT_DIR: git,
GIT_WORK_TREE: app.path.root,
})
.quiet()
.nothrow()
log.info("initialized")
}
await $`git --git-dir ${git} add .`.quiet().cwd(app.path.cwd).nothrow()
log.info("added files")
const result =
await $`git --git-dir ${git} commit --allow-empty -m "snapshot" --author="opencode <mail@opencode.ai>"`
.quiet()
.cwd(app.path.cwd)
.nothrow()
log.info("commit")
const match = result.stdout.toString().match(/\[.+ ([a-f0-9]+)\]/)
if (!match) return
return match![1]
}
export async function restore(sessionID: string, commit: string) {
log.info("restore", { commit })
const app = App.info()
const git = gitdir(sessionID)
await $`git --git-dir=${git} checkout ${commit} --force`.quiet().cwd(app.path.root)
}
function gitdir(sessionID: string) {
const app = App.info()
return path.join(app.path.data, "snapshot", sessionID)
}
}

View File

@@ -4,44 +4,80 @@ import { Bus } from "../bus"
import path from "path"
import z from "zod"
import fs from "fs/promises"
import { MessageV2 } from "../session/message-v2"
export namespace Storage {
const log = Log.create({ service: "storage" })
export const Event = {
Write: Bus.event(
"storage.write",
z.object({ key: z.string(), content: z.any() }),
),
Write: Bus.event("storage.write", z.object({ key: z.string(), content: z.any() })),
}
const state = App.state("storage", () => {
type Migration = (dir: string) => Promise<void>
const MIGRATIONS: Migration[] = [
async (dir: string) => {
try {
const files = new Bun.Glob("session/message/*/*.json").scanSync({
cwd: dir,
absolute: true,
})
for (const file of files) {
const content = await Bun.file(file).json()
if (!content.metadata) continue
log.info("migrating to v2 message", { file })
try {
const result = MessageV2.fromV1(content)
await Bun.write(file, JSON.stringify(result, null, 2))
} catch (e) {
await fs.rename(file, file.replace("storage", "broken"))
}
}
} catch {}
},
]
const state = App.state("storage", async () => {
const app = App.info()
const dir = path.join(app.path.data, "storage")
log.info("init", { path: dir })
const dir = path.normalize(path.join(app.path.data, "storage"))
await fs.mkdir(dir, { recursive: true })
const migration = await Bun.file(path.join(dir, "migration"))
.json()
.then((x) => parseInt(x))
.catch(() => 0)
for (let index = migration; index < MIGRATIONS.length; index++) {
log.info("running migration", { index })
const migration = MIGRATIONS[index]
await migration(dir)
await Bun.write(path.join(dir, "migration"), (index + 1).toString())
}
return {
dir,
}
})
export async function remove(key: string) {
const target = path.join(state().dir, key + ".json")
const dir = await state().then((x) => x.dir)
const target = path.join(dir, key + ".json")
await fs.unlink(target).catch(() => {})
}
export async function removeDir(key: string) {
const target = path.join(state().dir, key)
const dir = await state().then((x) => x.dir)
const target = path.join(dir, key)
await fs.rm(target, { recursive: true, force: true }).catch(() => {})
}
export async function readJSON<T>(key: string) {
return Bun.file(path.join(state().dir, key + ".json")).json() as Promise<T>
const dir = await state().then((x) => x.dir)
return Bun.file(path.join(dir, key + ".json")).json() as Promise<T>
}
export async function writeJSON<T>(key: string, content: T) {
const target = path.join(state().dir, key + ".json")
const dir = await state().then((x) => x.dir)
const target = path.join(dir, key + ".json")
const tmp = target + Date.now() + ".tmp"
await Bun.write(tmp, JSON.stringify(content))
await Bun.write(tmp, JSON.stringify(content, null, 2))
await fs.rename(tmp, target).catch(() => {})
await fs.unlink(tmp).catch(() => {})
Bus.publish(Event.Write, { key, content })
@@ -49,9 +85,10 @@ export namespace Storage {
const glob = new Bun.Glob("**/*")
export async function* list(prefix: string) {
const dir = await state().then((x) => x.dir)
try {
for await (const item of glob.scan({
cwd: path.join(state().dir, prefix),
cwd: path.join(dir, prefix),
onlyFiles: true,
})) {
const result = path.join(prefix, item.slice(0, -5))

View File

@@ -4,25 +4,6 @@ import DESCRIPTION from "./bash.txt"
import { App } from "../app/app"
const MAX_OUTPUT_LENGTH = 30000
const BANNED_COMMANDS = [
"alias",
"curl",
"curlie",
"wget",
"axel",
"aria2c",
"nc",
"telnet",
"lynx",
"w3m",
"links",
"httpie",
"xh",
"http-prompt",
"chrome",
"firefox",
"safari",
]
const DEFAULT_TIMEOUT = 1 * 60 * 1000
const MAX_TIMEOUT = 10 * 60 * 1000
@@ -31,12 +12,7 @@ export const BashTool = Tool.define({
description: DESCRIPTION,
parameters: z.object({
command: z.string().describe("The command to execute"),
timeout: z
.number()
.min(0)
.max(MAX_TIMEOUT)
.describe("Optional timeout in milliseconds")
.optional(),
timeout: z.number().min(0).max(MAX_TIMEOUT).describe("Optional timeout in milliseconds").optional(),
description: z
.string()
.describe(
@@ -45,8 +21,6 @@ export const BashTool = Tool.define({
}),
async execute(params, ctx) {
const timeout = Math.min(params.timeout ?? DEFAULT_TIMEOUT, MAX_TIMEOUT)
if (BANNED_COMMANDS.some((item) => params.command.startsWith(item)))
throw new Error(`Command '${params.command}' is not allowed`)
const process = Bun.spawn({
cmd: ["bash", "-c", params.command],
@@ -62,21 +36,14 @@ export const BashTool = Tool.define({
const stderr = await new Response(process.stderr).text()
return {
title: params.command,
metadata: {
stderr,
stdout,
exit: process.exitCode,
description: params.description,
title: params.command,
},
output: [
`<stdout>`,
stdout ?? "",
`</stdout>`,
`<stderr>`,
stderr ?? "",
`</stderr>`,
].join("\n"),
output: [`<stdout>`, stdout ?? "", `</stdout>`, `<stderr>`, stderr ?? "", `</stderr>`].join("\n"),
}
},
})

View File

@@ -20,15 +20,8 @@ export const EditTool = Tool.define({
parameters: z.object({
filePath: z.string().describe("The absolute path to the file to modify"),
oldString: z.string().describe("The text to replace"),
newString: z
.string()
.describe(
"The text to replace it with (must be different from old_string)",
),
replaceAll: z
.boolean()
.optional()
.describe("Replace all occurrences of old_string (default false)"),
newString: z.string().describe("The text to replace it with (must be different from old_string)"),
replaceAll: z.boolean().optional().describe("Replace all occurrences of old_string (default false)"),
}),
async execute(params, ctx) {
if (!params.filePath) {
@@ -40,9 +33,7 @@ export const EditTool = Tool.define({
}
const app = App.info()
const filepath = path.isAbsolute(params.filePath)
? params.filePath
: path.join(app.path.cwd, params.filePath)
const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(app.path.cwd, params.filePath)
await Permission.ask({
id: "edit",
@@ -70,17 +61,11 @@ export const EditTool = Tool.define({
const file = Bun.file(filepath)
const stats = await file.stat().catch(() => {})
if (!stats) throw new Error(`File ${filepath} not found`)
if (stats.isDirectory())
throw new Error(`Path is a directory, not a file: ${filepath}`)
if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filepath}`)
await FileTime.assert(ctx.sessionID, filepath)
contentOld = await file.text()
contentNew = replace(
contentOld,
params.oldString,
params.newString,
params.replaceAll,
)
contentNew = replace(contentOld, params.oldString, params.newString, params.replaceAll)
await file.write(contentNew)
await Bus.publish(File.Event.Edited, {
file: filepath,
@@ -88,9 +73,7 @@ export const EditTool = Tool.define({
contentNew = await file.text()
})()
const diff = trimDiff(
createTwoFilesPatch(filepath, filepath, contentOld, contentNew),
)
const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, contentNew))
FileTime.read(ctx.sessionID, filepath)
@@ -110,17 +93,14 @@ export const EditTool = Tool.define({
metadata: {
diagnostics,
diff,
title: `${path.relative(app.path.root, filepath)}`,
},
title: `${path.relative(app.path.root, filepath)}`,
output,
}
},
})
export type Replacer = (
content: string,
find: string,
) => Generator<string, void, unknown>
export type Replacer = (content: string, find: string) => Generator<string, void, unknown>
export const SimpleReplacer: Replacer = function* (_content, find) {
yield find
@@ -208,10 +188,7 @@ export const BlockAnchorReplacer: Replacer = function* (content, find) {
}
}
export const WhitespaceNormalizedReplacer: Replacer = function* (
content,
find,
) {
export const WhitespaceNormalizedReplacer: Replacer = function* (content, find) {
const normalizeWhitespace = (text: string) => text.replace(/\s+/g, " ").trim()
const normalizedFind = normalizeWhitespace(find)
@@ -229,9 +206,7 @@ export const WhitespaceNormalizedReplacer: Replacer = function* (
// Find the actual substring in the original line that matches
const words = find.trim().split(/\s+/)
if (words.length > 0) {
const pattern = words
.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
.join("\\s+")
const pattern = words.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\s+")
try {
const regex = new RegExp(pattern)
const match = line.match(regex)
@@ -270,9 +245,7 @@ export const IndentationFlexibleReplacer: Replacer = function* (content, find) {
}),
)
return lines
.map((line) => (line.trim().length === 0 ? line : line.slice(minIndent)))
.join("\n")
return lines.map((line) => (line.trim().length === 0 ? line : line.slice(minIndent))).join("\n")
}
const normalizedFind = removeIndentation(find)
@@ -423,10 +396,7 @@ export const ContextAwareReplacer: Replacer = function* (content, find) {
}
}
if (
totalNonEmptyLines === 0 ||
matchingLines / totalNonEmptyLines >= 0.5
) {
if (totalNonEmptyLines === 0 || matchingLines / totalNonEmptyLines >= 0.5) {
yield block
break // Only match the first occurrence
}
@@ -473,12 +443,7 @@ function trimDiff(diff: string): string {
return trimmedLines.join("\n")
}
export function replace(
content: string,
oldString: string,
newString: string,
replaceAll = false,
): string {
export function replace(content: string, oldString: string, newString: string, replaceAll = false): string {
if (oldString === newString) {
throw new Error("oldString and newString must be different")
}
@@ -489,10 +454,10 @@ export function replace(
BlockAnchorReplacer,
WhitespaceNormalizedReplacer,
IndentationFlexibleReplacer,
EscapeNormalizedReplacer,
TrimmedBoundaryReplacer,
ContextAwareReplacer,
MultiOccurrenceReplacer,
// EscapeNormalizedReplacer,
// TrimmedBoundaryReplacer,
// ContextAwareReplacer,
// MultiOccurrenceReplacer,
]) {
for (const search of replacer(content, oldString)) {
const index = content.indexOf(search)
@@ -502,11 +467,7 @@ export function replace(
}
const lastIndex = content.lastIndexOf(search)
if (index !== lastIndex) continue
return (
content.substring(0, index) +
newString +
content.substring(index + search.length)
)
return content.substring(0, index) + newString + content.substring(index + search.length)
}
}
throw new Error("oldString not found in content or was found multiple times")

View File

@@ -20,16 +20,14 @@ export const GlobTool = Tool.define({
async execute(params) {
const app = App.info()
let search = params.path ?? app.path.cwd
search = path.isAbsolute(search)
? search
: path.resolve(app.path.cwd, search)
search = path.isAbsolute(search) ? search : path.resolve(app.path.cwd, search)
const limit = 100
const files = []
let truncated = false
for (const file of await Ripgrep.files({
cwd: search,
glob: params.pattern,
glob: [params.pattern],
})) {
if (files.length >= limit) {
truncated = true
@@ -53,17 +51,15 @@ export const GlobTool = Tool.define({
output.push(...files.map((f) => f.path))
if (truncated) {
output.push("")
output.push(
"(Results are truncated. Consider using a more specific path or pattern.)",
)
output.push("(Results are truncated. Consider using a more specific path or pattern.)")
}
}
return {
title: path.relative(app.path.root, search),
metadata: {
count: files.length,
truncated,
title: path.relative(app.path.root, search),
},
output: output.join("\n"),
}

View File

@@ -9,21 +9,9 @@ export const GrepTool = Tool.define({
id: "grep",
description: DESCRIPTION,
parameters: z.object({
pattern: z
.string()
.describe("The regex pattern to search for in file contents"),
path: z
.string()
.optional()
.describe(
"The directory to search in. Defaults to the current working directory.",
),
include: z
.string()
.optional()
.describe(
'File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")',
),
pattern: z.string().describe("The regex pattern to search for in file contents"),
path: z.string().optional().describe("The directory to search in. Defaults to the current working directory."),
include: z.string().optional().describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'),
}),
async execute(params) {
if (!params.pattern) {
@@ -51,7 +39,8 @@ export const GrepTool = Tool.define({
if (exitCode === 1) {
return {
metadata: { matches: 0, truncated: false, title: params.pattern },
title: params.pattern,
metadata: { matches: 0, truncated: false },
output: "No files found",
}
}
@@ -93,7 +82,8 @@ export const GrepTool = Tool.define({
if (finalMatches.length === 0) {
return {
metadata: { matches: 0, truncated: false, title: params.pattern },
title: params.pattern,
metadata: { matches: 0, truncated: false },
output: "No files found",
}
}
@@ -114,16 +104,14 @@ export const GrepTool = Tool.define({
if (truncated) {
outputLines.push("")
outputLines.push(
"(Results are truncated. Consider using a more specific path or pattern.)",
)
outputLines.push("(Results are truncated. Consider using a more specific path or pattern.)")
}
return {
title: params.pattern,
metadata: {
matches: finalMatches.length,
truncated,
title: params.pattern,
},
output: outputLines.join("\n"),
}

View File

@@ -24,16 +24,8 @@ export const ListTool = Tool.define({
id: "list",
description: DESCRIPTION,
parameters: z.object({
path: z
.string()
.describe(
"The absolute path to the directory to list (must be absolute, not relative)",
)
.optional(),
ignore: z
.array(z.string())
.describe("List of glob patterns to ignore")
.optional(),
path: z.string().describe("The absolute path to the directory to list (must be absolute, not relative)").optional(),
ignore: z.array(z.string()).describe("List of glob patterns to ignore").optional(),
}),
async execute(params) {
const app = App.info()
@@ -44,8 +36,7 @@ export const ListTool = Tool.define({
for await (const file of glob.scan({ cwd: searchPath, dot: true })) {
if (IGNORE_PATTERNS.some((p) => file.includes(p))) continue
if (params.ignore?.some((pattern) => new Bun.Glob(pattern).match(file)))
continue
if (params.ignore?.some((pattern) => new Bun.Glob(pattern).match(file))) continue
files.push(file)
if (files.length >= LIMIT) break
}
@@ -99,10 +90,10 @@ export const ListTool = Tool.define({
const output = `${searchPath}/\n` + renderDir(".", 0)
return {
title: path.relative(app.path.root, searchPath),
metadata: {
count: files.length,
truncated: files.length >= LIMIT,
title: path.relative(app.path.root, searchPath),
},
output,
}

View File

@@ -13,20 +13,16 @@ export const LspDiagnosticTool = Tool.define({
}),
execute: async (args) => {
const app = App.info()
const normalized = path.isAbsolute(args.path)
? args.path
: path.join(app.path.cwd, args.path)
const normalized = path.isAbsolute(args.path) ? args.path : path.join(app.path.cwd, args.path)
await LSP.touchFile(normalized, true)
const diagnostics = await LSP.diagnostics()
const file = diagnostics[normalized]
return {
title: path.relative(app.path.root, normalized),
metadata: {
diagnostics,
title: path.relative(app.path.root, normalized),
},
output: file?.length
? file.map(LSP.Diagnostic.pretty).join("\n")
: "No errors found",
output: file?.length ? file.map(LSP.Diagnostic.pretty).join("\n") : "No errors found",
}
},
})

View File

@@ -15,9 +15,7 @@ export const LspHoverTool = Tool.define({
}),
execute: async (args) => {
const app = App.info()
const file = path.isAbsolute(args.file)
? args.file
: path.join(app.path.cwd, args.file)
const file = path.isAbsolute(args.file) ? args.file : path.join(app.path.cwd, args.file)
await LSP.touchFile(file, true)
const result = await LSP.hover({
...args,
@@ -25,14 +23,9 @@ export const LspHoverTool = Tool.define({
})
return {
title: path.relative(app.path.root, file) + ":" + args.line + ":" + args.character,
metadata: {
result,
title:
path.relative(app.path.root, file) +
":" +
args.line +
":" +
args.character,
},
output: JSON.stringify(result, null, 2),
}

View File

@@ -10,9 +10,7 @@ export const MultiEditTool = Tool.define({
description: DESCRIPTION,
parameters: z.object({
filePath: z.string().describe("The absolute path to the file to modify"),
edits: z
.array(EditTool.parameters)
.describe("Array of edit operations to perform sequentially on the file"),
edits: z.array(EditTool.parameters).describe("Array of edit operations to perform sequentially on the file"),
}),
async execute(params, ctx) {
const results = []
@@ -30,9 +28,9 @@ export const MultiEditTool = Tool.define({
}
const app = App.info()
return {
title: path.relative(app.path.root, params.filePath),
metadata: {
results: results.map((r) => r.metadata),
title: path.relative(app.path.root, params.filePath),
},
output: results.at(-1)!.output,
}

View File

@@ -6,9 +6,7 @@ import { FileTime } from "../file/time"
import DESCRIPTION from "./patch.txt"
const PatchParams = z.object({
patchText: z
.string()
.describe("The full patch text that describes all changes to be made"),
patchText: z.string().describe("The full patch text that describes all changes to be made"),
})
interface Change {
@@ -42,10 +40,7 @@ function identifyFilesNeeded(patchText: string): string[] {
const files: string[] = []
const lines = patchText.split("\n")
for (const line of lines) {
if (
line.startsWith("*** Update File:") ||
line.startsWith("*** Delete File:")
) {
if (line.startsWith("*** Update File:") || line.startsWith("*** Delete File:")) {
const filePath = line.split(":", 2)[1]?.trim()
if (filePath) files.push(filePath)
}
@@ -65,10 +60,7 @@ function identifyFilesAdded(patchText: string): string[] {
return files
}
function textToPatch(
patchText: string,
_currentFiles: Record<string, string>,
): [PatchOperation[], number] {
function textToPatch(patchText: string, _currentFiles: Record<string, string>): [PatchOperation[], number] {
const operations: PatchOperation[] = []
const lines = patchText.split("\n")
let i = 0
@@ -93,11 +85,7 @@ function textToPatch(
const changes: PatchChange[] = []
i++
while (
i < lines.length &&
!lines[i].startsWith("@@") &&
!lines[i].startsWith("***")
) {
while (i < lines.length && !lines[i].startsWith("@@") && !lines[i].startsWith("***")) {
const changeLine = lines[i]
if (changeLine.startsWith(" ")) {
changes.push({ type: "keep", content: changeLine.substring(1) })
@@ -151,10 +139,7 @@ function textToPatch(
return [operations, fuzz]
}
function patchToCommit(
operations: PatchOperation[],
currentFiles: Record<string, string>,
): Commit {
function patchToCommit(operations: PatchOperation[], currentFiles: Record<string, string>): Commit {
const changes: Record<string, Change> = {}
for (const op of operations) {
@@ -173,9 +158,7 @@ function patchToCommit(
const lines = originalContent.split("\n")
for (const hunk of op.hunks) {
const contextIndex = lines.findIndex((line) =>
line.includes(hunk.contextLine),
)
const contextIndex = lines.findIndex((line) => line.includes(hunk.contextLine))
if (contextIndex === -1) {
throw new Error(`Context line not found: ${hunk.contextLine}`)
}
@@ -204,11 +187,7 @@ function patchToCommit(
return { changes }
}
function generateDiff(
oldContent: string,
newContent: string,
filePath: string,
): [string, number, number] {
function generateDiff(oldContent: string, newContent: string, filePath: string): [string, number, number] {
// Mock implementation - would need actual diff generation
const lines1 = oldContent.split("\n")
const lines2 = newContent.split("\n")
@@ -296,9 +275,7 @@ export const PatchTool = Tool.define({
// Process the patch
const [patch, fuzz] = textToPatch(params.patchText, currentFiles)
if (fuzz > 3) {
throw new Error(
`patch contains fuzzy matches (fuzz level: ${fuzz}). Please make your context lines more precise`,
)
throw new Error(`patch contains fuzzy matches (fuzz level: ${fuzz}). Please make your context lines more precise`)
}
// Convert patch to commit
@@ -343,11 +320,7 @@ export const PatchTool = Tool.define({
const newContent = change.new_content || ""
// Calculate diff statistics
const [, additions, removals] = generateDiff(
oldContent,
newContent,
filePath,
)
const [, additions, removals] = generateDiff(oldContent, newContent, filePath)
totalAdditions += additions
totalRemovals += removals
@@ -358,11 +331,11 @@ export const PatchTool = Tool.define({
const output = result
return {
title: `${filesToRead.length} files`,
metadata: {
changed: changedFiles,
additions: totalAdditions,
removals: totalRemovals,
title: `${filesToRead.length} files`,
},
output,
}

View File

@@ -16,14 +16,8 @@ export const ReadTool = Tool.define({
description: DESCRIPTION,
parameters: z.object({
filePath: z.string().describe("The path to the file to read"),
offset: z
.number()
.describe("The line number to start reading from (0-based)")
.optional(),
limit: z
.number()
.describe("The number of lines to read (defaults to 2000)")
.optional(),
offset: z.number().describe("The line number to start reading from (0-based)").optional(),
limit: z.number().describe("The number of lines to read (defaults to 2000)").optional(),
}),
async execute(params, ctx) {
let filePath = params.filePath
@@ -40,16 +34,13 @@ export const ReadTool = Tool.define({
const suggestions = dirEntries
.filter(
(entry) =>
entry.toLowerCase().includes(base.toLowerCase()) ||
base.toLowerCase().includes(entry.toLowerCase()),
entry.toLowerCase().includes(base.toLowerCase()) || base.toLowerCase().includes(entry.toLowerCase()),
)
.map((entry) => path.join(dir, entry))
.slice(0, 3)
if (suggestions.length > 0) {
throw new Error(
`File not found: ${filePath}\n\nDid you mean one of these?\n${suggestions.join("\n")}`,
)
throw new Error(`File not found: ${filePath}\n\nDid you mean one of these?\n${suggestions.join("\n")}`)
}
throw new Error(`File not found: ${filePath}`)
@@ -57,21 +48,14 @@ export const ReadTool = Tool.define({
const stats = await file.stat()
if (stats.size > MAX_READ_SIZE)
throw new Error(
`File is too large (${stats.size} bytes). Maximum size is ${MAX_READ_SIZE} bytes`,
)
throw new Error(`File is too large (${stats.size} bytes). Maximum size is ${MAX_READ_SIZE} bytes`)
const limit = params.limit ?? DEFAULT_READ_LIMIT
const offset = params.offset || 0
const isImage = isImageFile(filePath)
if (isImage)
throw new Error(
`This is an image file of type: ${isImage}\nUse a different tool to process images`,
)
if (isImage) throw new Error(`This is an image file of type: ${isImage}\nUse a different tool to process images`)
const lines = await file.text().then((text) => text.split("\n"))
const raw = lines.slice(offset, offset + limit).map((line) => {
return line.length > MAX_LINE_LENGTH
? line.substring(0, MAX_LINE_LENGTH) + "..."
: line
return line.length > MAX_LINE_LENGTH ? line.substring(0, MAX_LINE_LENGTH) + "..." : line
})
const content = raw.map((line, index) => {
return `${(index + offset + 1).toString().padStart(5, "0")}| ${line}`
@@ -82,21 +66,19 @@ export const ReadTool = Tool.define({
output += content.join("\n")
if (lines.length > offset + content.length) {
output += `\n\n(File has more lines. Use 'offset' parameter to read beyond line ${
offset + content.length
})`
output += `\n\n(File has more lines. Use 'offset' parameter to read beyond line ${offset + content.length})`
}
output += "\n</file>"
// just warms the lsp client
await LSP.touchFile(filePath, true)
await LSP.touchFile(filePath, false)
FileTime.read(ctx.sessionID, filePath)
return {
title: path.relative(App.info().path.root, filePath),
output,
metadata: {
preview,
title: path.relative(App.info().path.root, filePath),
},
}
},

View File

@@ -2,12 +2,12 @@ Reads a file from the local filesystem. You can access any file directly by usin
Assume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.
Usage:
- The file_path parameter must be an absolute path, not a relative path
- The filePath parameter must be an absolute path, not a relative path
- By default, it reads up to 2000 lines starting from the beginning of the file
- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters
- Any lines longer than 2000 characters will be truncated
- Results are returned using cat -n format, with line numbers starting at 1
- This tool allows OpenCode to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as OpenCode is a multimodal LLM.
- This tool allows opencode to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as opencode is a multimodal LLM.
- You have the capability to call multiple tools in a single response. It is always better to speculatively read multiple files as a batch that are potentially useful.
- You will regularly be asked to read screenshots. If the user provides a path to a screenshot ALWAYS use this tool to view the file at the path. This tool will work with all temporary file paths like /var/folders/123/abc/T/TemporaryItems/NSIRD_screencaptureui_ZfB1tD/Screenshot.png
- If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents.

View File

@@ -3,41 +3,36 @@ import DESCRIPTION from "./task.txt"
import { z } from "zod"
import { Session } from "../session"
import { Bus } from "../bus"
import { Message } from "../session/message"
import { MessageV2 } from "../session/message-v2"
export const TaskTool = Tool.define({
id: "task",
description: DESCRIPTION,
parameters: z.object({
description: z
.string()
.describe("A short (3-5 words) description of the task"),
description: z.string().describe("A short (3-5 words) description of the task"),
prompt: z.string().describe("The task for the agent to perform"),
}),
async execute(params, ctx) {
const session = await Session.create(ctx.sessionID)
const msg = await Session.getMessage(ctx.sessionID, ctx.messageID)
const metadata = msg.metadata.assistant!
const msg = (await Session.getMessage(ctx.sessionID, ctx.messageID)) as MessageV2.Assistant
function summary(input: Message.Info) {
function summary(input: MessageV2.Info) {
const result = []
for (const part of input.parts) {
if (part.type === "tool-invocation") {
result.push({
toolInvocation: part.toolInvocation,
metadata: input.metadata.tool[part.toolInvocation.toolCallId],
})
if (part.type === "tool" && part.state.status === "completed") {
result.push(part)
}
}
return result
}
const unsub = Bus.subscribe(Message.Event.Updated, async (evt) => {
if (evt.properties.info.metadata.sessionID !== session.id) return
const unsub = Bus.subscribe(MessageV2.Event.Updated, async (evt) => {
if (evt.properties.info.sessionID !== session.id) return
ctx.metadata({
title: params.description,
summary: summary(evt.properties.info),
metadata: {
summary: summary(evt.properties.info),
},
})
})
@@ -46,8 +41,8 @@ export const TaskTool = Tool.define({
})
const result = await Session.chat({
sessionID: session.id,
modelID: metadata.modelID,
providerID: metadata.providerID,
modelID: msg.modelID,
providerID: msg.providerID,
parts: [
{
type: "text",
@@ -57,8 +52,8 @@ export const TaskTool = Tool.define({
})
unsub()
return {
title: params.description,
metadata: {
title: params.description,
summary: summary(result),
},
output: result.parts.findLast((x) => x.type === "text")!.text,

View File

@@ -5,12 +5,8 @@ import { App } from "../app/app"
const TodoInfo = z.object({
content: z.string().min(1).describe("Brief description of the task"),
status: z
.enum(["pending", "in_progress", "completed"])
.describe("Current status of the task"),
priority: z
.enum(["high", "medium", "low"])
.describe("Priority level of the task"),
status: z.enum(["pending", "in_progress", "completed", "cancelled"]).describe("Current status of the task"),
priority: z.enum(["high", "medium", "low"]).describe("Priority level of the task"),
id: z.string().describe("Unique identifier for the todo item"),
})
type TodoInfo = z.infer<typeof TodoInfo>
@@ -32,9 +28,9 @@ export const TodoWriteTool = Tool.define({
const todos = state()
todos[opts.sessionID] = params.todos
return {
title: `${params.todos.filter((x) => x.status !== "completed").length} todos`,
output: JSON.stringify(params.todos, null, 2),
metadata: {
title: `${params.todos.filter((x) => x.status !== "completed").length} todos`,
todos: params.todos,
},
}
@@ -48,9 +44,9 @@ export const TodoReadTool = Tool.define({
async execute(_params, opts) {
const todos = state()[opts.sessionID] ?? []
return {
title: `${todos.filter((x) => x.status !== "completed").length} todos`,
metadata: {
todos,
title: `${todos.filter((x) => x.status !== "completed").length} todos`,
},
output: JSON.stringify(todos, null, 2),
}

View File

@@ -2,19 +2,15 @@ import type { StandardSchemaV1 } from "@standard-schema/spec"
export namespace Tool {
interface Metadata {
title: string
[key: string]: any
}
export type Context<M extends Metadata = Metadata> = {
sessionID: string
messageID: string
abort: AbortSignal
metadata(meta: M): void
metadata(input: { title?: string; metadata?: M }): void
}
export interface Info<
Parameters extends StandardSchemaV1 = StandardSchemaV1,
M extends Metadata = Metadata,
> {
export interface Info<Parameters extends StandardSchemaV1 = StandardSchemaV1, M extends Metadata = Metadata> {
id: string
description: string
parameters: Parameters
@@ -22,15 +18,15 @@ export namespace Tool {
args: StandardSchemaV1.InferOutput<Parameters>,
ctx: Context,
): Promise<{
title: string
metadata: M
output: string
}>
}
export function define<
Parameters extends StandardSchemaV1,
Result extends Metadata,
>(input: Info<Parameters, Result>): Info<Parameters, Result> {
export function define<Parameters extends StandardSchemaV1, Result extends Metadata>(
input: Info<Parameters, Result>,
): Info<Parameters, Result> {
return input
}
}

View File

@@ -14,9 +14,7 @@ export const WebFetchTool = Tool.define({
url: z.string().describe("The URL to fetch content from"),
format: z
.enum(["text", "markdown", "html"])
.describe(
"The format to return the content in (text, markdown, or html)",
),
.describe("The format to return the content in (text, markdown, or html)"),
timeout: z
.number()
.min(0)
@@ -26,17 +24,11 @@ export const WebFetchTool = Tool.define({
}),
async execute(params, ctx) {
// Validate URL
if (
!params.url.startsWith("http://") &&
!params.url.startsWith("https://")
) {
if (!params.url.startsWith("http://") && !params.url.startsWith("https://")) {
throw new Error("URL must start with http:// or https://")
}
const timeout = Math.min(
(params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000,
MAX_TIMEOUT,
)
const timeout = Math.min((params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000, MAX_TIMEOUT)
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
@@ -46,8 +38,7 @@ export const WebFetchTool = Tool.define({
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
Accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
},
})
@@ -79,16 +70,14 @@ export const WebFetchTool = Tool.define({
const text = await extractTextFromHTML(content)
return {
output: text,
metadata: {
title,
},
title,
metadata: {},
}
}
return {
output: content,
metadata: {
title,
},
title,
metadata: {},
}
case "markdown":
@@ -96,32 +85,28 @@ export const WebFetchTool = Tool.define({
const markdown = convertHTMLToMarkdown(content)
return {
output: markdown,
metadata: {
title,
},
title,
metadata: {},
}
}
return {
output: "```\n" + content + "\n```",
metadata: {
title,
},
title,
metadata: {},
}
case "html":
return {
output: content,
metadata: {
title,
},
title,
metadata: {},
}
default:
return {
output: content,
metadata: {
title,
},
title,
metadata: {},
}
}
},
@@ -143,16 +128,7 @@ async function extractTextFromHTML(html: string) {
.on("*", {
element(element) {
// Reset skip flag when entering other elements
if (
![
"script",
"style",
"noscript",
"iframe",
"object",
"embed",
].includes(element.tagName)
) {
if (!["script", "style", "noscript", "iframe", "object", "embed"].includes(element.tagName)) {
skipContent = false
}
},

View File

@@ -13,18 +13,12 @@ export const WriteTool = Tool.define({
id: "write",
description: DESCRIPTION,
parameters: z.object({
filePath: z
.string()
.describe(
"The absolute path to the file to write (must be absolute, not relative)",
),
filePath: z.string().describe("The absolute path to the file to write (must be absolute, not relative)"),
content: z.string().describe("The content to write to the file"),
}),
async execute(params, ctx) {
const app = App.info()
const filepath = path.isAbsolute(params.filePath)
? params.filePath
: path.join(app.path.cwd, params.filePath)
const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(app.path.cwd, params.filePath)
const file = Bun.file(filepath)
const exists = await file.exists()
@@ -33,9 +27,7 @@ export const WriteTool = Tool.define({
await Permission.ask({
id: "write",
sessionID: ctx.sessionID,
title: exists
? "Overwrite this file: " + filepath
: "Create new file: " + filepath,
title: exists ? "Overwrite this file: " + filepath : "Create new file: " + filepath,
metadata: {
filePath: filepath,
content: params.content,
@@ -62,11 +54,11 @@ export const WriteTool = Tool.define({
}
return {
title: path.relative(app.path.root, filepath),
metadata: {
diagnostics,
filepath,
exists: exists,
title: path.relative(app.path.root, filepath),
},
output,
}

View File

@@ -7,10 +7,7 @@ export abstract class NamedError extends Error {
abstract schema(): ZodSchema
abstract toObject(): { name: string; data: any }
static create<Name extends string, Data extends ZodSchema>(
name: Name,
data: Data,
) {
static create<Name extends string, Data extends ZodSchema>(name: Name, data: Data) {
const schema = z
.object({
name: z.literal(name),

View File

@@ -1,7 +1,17 @@
import { exists } from "fs/promises"
import { dirname, join } from "path"
import { dirname, join, relative } from "path"
export namespace Filesystem {
export function overlaps(a: string, b: string) {
const relA = relative(a, b)
const relB = relative(b, a)
return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..")
}
export function contains(parent: string, child: string) {
return relative(parent, child).startsWith("..")
}
export async function findUp(target: string, start: string, stop?: string) {
let current = start
const result = []
@@ -15,4 +25,28 @@ export namespace Filesystem {
}
return result
}
export async function globUp(pattern: string, start: string, stop?: string) {
let current = start
const result = []
while (true) {
try {
const glob = new Bun.Glob(pattern)
for await (const match of glob.scan({
cwd: current,
onlyFiles: true,
dot: true,
})) {
result.push(join(current, match))
}
} catch {
// Skip invalid glob patterns
}
if (stop === current) break
const parent = dirname(current)
if (parent === current) break
current = parent
}
return result
}
}

View File

@@ -4,6 +4,7 @@ export function lazy<T>(fn: () => T) {
return (): T => {
if (loaded) return value as T
loaded = true
value = fn()
return value as T
}

View File

@@ -19,10 +19,7 @@ export namespace Log {
await fs.mkdir(dir, { recursive: true })
cleanup(dir)
if (options.print) return
logpath = path.join(
dir,
new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log",
)
logpath = path.join(dir, new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log")
const logfile = Bun.file(logpath)
await fs.truncate(logpath).catch(() => {})
const writer = logfile.writer()
@@ -43,9 +40,7 @@ export namespace Log {
const filesToDelete = files.slice(0, -10)
await Promise.all(
filesToDelete.map((file) => fs.unlink(file).catch(() => {})),
)
await Promise.all(filesToDelete.map((file) => fs.unlink(file).catch(() => {})))
}
let last = Date.now()
@@ -63,11 +58,7 @@ export namespace Log {
const next = new Date()
const diff = next.getTime() - last
last = next.getTime()
return (
[next.toISOString().split(".")[0], "+" + diff + "ms", prefix, message]
.filter(Boolean)
.join(" ") + "\n"
)
return [next.toISOString().split(".")[0], "+" + diff + "ms", prefix, message].filter(Boolean).join(" ") + "\n"
}
const result = {
info(message?: any, extra?: Record<string, any>) {

View File

@@ -0,0 +1,14 @@
export function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
let timeout: NodeJS.Timeout
return Promise.race([
promise.then((result) => {
clearTimeout(timeout)
return result
}),
new Promise<never>((_, reject) => {
timeout = setTimeout(() => {
reject(new Error(`Operation timed out after ${ms}ms`))
}, ms)
}),
])
}

View File

@@ -17,12 +17,7 @@ const testCases: TestCase[] = [
replace: 'console.log("universe");',
},
{
content: [
"if (condition) {",
" doSomething();",
" doSomethingElse();",
"}",
].join("\n"),
content: ["if (condition) {", " doSomething();", " doSomethingElse();", "}"].join("\n"),
find: [" doSomething();", " doSomethingElse();"].join("\n"),
replace: [" doNewThing();", " doAnotherThing();"].join("\n"),
},
@@ -53,15 +48,8 @@ const testCases: TestCase[] = [
" return result;",
"}",
].join("\n"),
find: [
"function calculate(a, b) {",
" // different middle content",
" return result;",
"}",
].join("\n"),
replace: ["function calculate(a, b) {", " return a * b * 2;", "}"].join(
"\n",
),
find: ["function calculate(a, b) {", " // different middle content", " return result;", "}"].join("\n"),
replace: ["function calculate(a, b) {", " return a * b * 2;", "}"].join("\n"),
},
{
content: [
@@ -76,13 +64,7 @@ const testCases: TestCase[] = [
"}",
].join("\n"),
find: ["class MyClass {", " // different implementation", "}"].join("\n"),
replace: [
"class MyClass {",
" constructor() {",
" this.value = 42;",
" }",
"}",
].join("\n"),
replace: ["class MyClass {", " constructor() {", " this.value = 42;", " }", "}"].join("\n"),
},
// WhitespaceNormalizedReplacer cases
@@ -104,48 +86,21 @@ const testCases: TestCase[] = [
// IndentationFlexibleReplacer cases
{
content: [
" function nested() {",
' console.log("deeply nested");',
" return true;",
" }",
].join("\n"),
find: [
"function nested() {",
' console.log("deeply nested");',
" return true;",
"}",
].join("\n"),
replace: [
"function nested() {",
' console.log("updated");',
" return false;",
"}",
].join("\n"),
content: [" function nested() {", ' console.log("deeply nested");', " return true;", " }"].join(
"\n",
),
find: ["function nested() {", ' console.log("deeply nested");', " return true;", "}"].join("\n"),
replace: ["function nested() {", ' console.log("updated");', " return false;", "}"].join("\n"),
},
{
content: [
" if (true) {",
' console.log("level 1");',
' console.log("level 2");',
" }",
].join("\n"),
find: [
"if (true) {",
'console.log("level 1");',
' console.log("level 2");',
"}",
].join("\n"),
content: [" if (true) {", ' console.log("level 1");', ' console.log("level 2");', " }"].join("\n"),
find: ["if (true) {", 'console.log("level 1");', ' console.log("level 2");', "}"].join("\n"),
replace: ["if (true) {", 'console.log("updated");', "}"].join("\n"),
},
// replaceAll option cases
{
content: [
'console.log("test");',
'console.log("test");',
'console.log("test");',
].join("\n"),
content: ['console.log("test");', 'console.log("test");', 'console.log("test");'].join("\n"),
find: 'console.log("test");',
replace: 'console.log("updated");',
all: true,
@@ -213,9 +168,7 @@ const testCases: TestCase[] = [
// MultiOccurrenceReplacer cases (with replaceAll)
{
content: ["debug('start');", "debug('middle');", "debug('end');"].join(
"\n",
),
content: ["debug('start');", "debug('middle');", "debug('end');"].join("\n"),
find: "debug",
replace: "log",
all: true,
@@ -239,9 +192,7 @@ const testCases: TestCase[] = [
replace: "const value = 24;",
},
{
content: ["", " if (condition) {", " doSomething();", " }", ""].join(
"\n",
),
content: ["", " if (condition) {", " doSomething();", " }", ""].join("\n"),
find: ["if (condition) {", " doSomething();", "}"].join("\n"),
replace: ["if (condition) {", " doNothing();", "}"].join("\n"),
},
@@ -262,9 +213,7 @@ const testCases: TestCase[] = [
" return result;",
"}",
].join("\n"),
replace: ["function calculate(a, b) {", " return (a + b) * 2;", "}"].join(
"\n",
),
replace: ["function calculate(a, b) {", " return (a + b) * 2;", "}"].join("\n"),
},
{
content: [
@@ -278,15 +227,8 @@ const testCases: TestCase[] = [
" }",
"}",
].join("\n"),
find: [
"class TestClass {",
" // different implementation",
" // with multiple lines",
"}",
].join("\n"),
replace: ["class TestClass {", " getValue() { return 42; }", "}"].join(
"\n",
),
find: ["class TestClass {", " // different implementation", " // with multiple lines", "}"].join("\n"),
replace: ["class TestClass {", " getValue() { return 42; }", "}"].join("\n"),
},
// Combined edge cases for new replacers
@@ -296,9 +238,7 @@ const testCases: TestCase[] = [
replace: 'console.log("updated");',
},
{
content: [" ", "function test() {", " return 'value';", "}", " "].join(
"\n",
),
content: [" ", "function test() {", " return 'value';", "}", " "].join("\n"),
find: ["function test() {", "return 'value';", "}"].join("\n"),
replace: ["function test() {", "return 'new value';", "}"].join("\n"),
},
@@ -346,13 +286,7 @@ const testCases: TestCase[] = [
// ContextAwareReplacer - test with trailing newline in find string
{
content: [
"class Test {",
" method1() {",
" return 1;",
" }",
"}",
].join("\n"),
content: ["class Test {", " method1() {", " return 1;", " }", "}"].join("\n"),
find: [
"class Test {",
" // different content",
@@ -401,12 +335,7 @@ describe("EditTool Replacers", () => {
replace(testCase.content, testCase.find, testCase.replace, testCase.all)
}).toThrow()
} else {
const result = replace(
testCase.content,
testCase.find,
testCase.replace,
testCase.all,
)
const result = replace(testCase.content, testCase.find, testCase.replace, testCase.all)
expect(result).toContain(testCase.replace)
}
})

View File

@@ -42,10 +42,7 @@ describe("tool.glob", () => {
describe("tool.ls", () => {
test("basic", async () => {
const result = await App.provide({ cwd: process.cwd() }, async () => {
return await ListTool.execute(
{ path: "./example", ignore: [".git"] },
ctx,
)
return await ListTool.execute({ path: "./example", ignore: [".git"] }, ctx)
})
expect(result.output).toMatchSnapshot()
})

View File

@@ -0,0 +1,12 @@
# These are supported funding model platforms
github: [changkun] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

View File

@@ -0,0 +1,71 @@
# Copyright 2021 The golang.design Initiative Authors.
# All rights reserved. Use of this source code is governed
# by a MIT license that can be found in the LICENSE file.
#
# Written by Changkun Ou <changkun.de>
name: clipboard
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
platform_test:
env:
DISPLAY: ':0.0'
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
go: ['1.24.x']
steps:
- name: Install and run dependencies (xvfb libx11-dev)
if: ${{ runner.os == 'Linux' }}
run: |
sudo apt update
sudo apt install -y xvfb libx11-dev x11-utils libegl1-mesa-dev libgles2-mesa-dev
Xvfb :0 -screen 0 1024x768x24 > /dev/null 2>&1 &
# Wait for Xvfb
MAX_ATTEMPTS=120 # About 60 seconds
COUNT=0
echo -n "Waiting for Xvfb to be ready..."
while ! xdpyinfo -display "${DISPLAY}" >/dev/null 2>&1; do
echo -n "."
sleep 0.50s
COUNT=$(( COUNT + 1 ))
if [ "${COUNT}" -ge "${MAX_ATTEMPTS}" ]; then
echo " Gave up waiting for X server on ${DISPLAY}"
exit 1
fi
done
echo "Done - Xvfb is ready!"
- uses: actions/checkout@v2
- uses: actions/setup-go@v2
with:
stable: 'false'
go-version: ${{ matrix.go }}
- name: Build (${{ matrix.go }})
run: |
go build -o gclip cmd/gclip/main.go
go build -o gclip-gui cmd/gclip-gui/main.go
- name: Run Tests with CGO_ENABLED=1 (${{ matrix.go }})
if: ${{ runner.os == 'Linux' || runner.os == 'macOS'}}
run: |
CGO_ENABLED=1 go test -v -covermode=atomic .
- name: Run Tests with CGO_ENABLED=0 (${{ matrix.go }})
if: ${{ runner.os == 'Linux' || runner.os == 'macOS'}}
run: |
CGO_ENABLED=0 go test -v -covermode=atomic .
- name: Run Tests on Windows (${{ matrix.go }})
if: ${{ runner.os == 'Windows'}}
run: |
go test -v -covermode=atomic .

15
packages/tui/clipboard/.gitignore vendored Normal file
View File

@@ -0,0 +1,15 @@
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/

View File

@@ -0,0 +1,13 @@
# Copyright 2021 The golang.design Initiative Authors.
# All rights reserved. Use of this source code is governed
# by a MIT license that can be found in the LICENSE file.
#
# Written by Changkun Ou <changkun.de>
FROM golang:1.24
RUN apt-get update && apt-get install -y \
xvfb libx11-dev libegl1-mesa-dev libgles2-mesa-dev \
&& apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
WORKDIR /app
COPY . .
CMD [ "sh", "-c", "./tests/test-docker.sh" ]

View File

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

View File

@@ -0,0 +1,162 @@
# clipboard [![PkgGoDev](https://pkg.go.dev/badge/golang.design/x/clipboard)](https://pkg.go.dev/golang.design/x/clipboard) ![](https://changkun.de/urlstat?mode=github&repo=golang-design/clipboard) ![clipboard](https://github.com/golang-design/clipboard/workflows/clipboard/badge.svg?branch=main)
Cross platform (macOS/Linux/Windows/Android/iOS) clipboard package in Go
```go
import "golang.design/x/clipboard"
```
## Features
- Cross platform supports: **macOS, Linux (X11), Windows, iOS, and Android**
- Copy/paste UTF-8 text
- Copy/paste PNG encoded images (Desktop-only)
- Command `gclip` as a demo application
- Mobile app `gclip-gui` as a demo application
## API Usage
Package clipboard provides cross platform clipboard access and supports
macOS/Linux/Windows/Android/iOS platform. Before interacting with the
clipboard, one must call Init to assert if it is possible to use this
package:
```go
// Init returns an error if the package is not ready for use.
err := clipboard.Init()
if err != nil {
panic(err)
}
```
The most common operations are `Read` and `Write`. To use them:
```go
// write/read text format data of the clipboard, and
// the byte buffer regarding the text are UTF8 encoded.
clipboard.Write(clipboard.FmtText, []byte("text data"))
clipboard.Read(clipboard.FmtText)
// write/read image format data of the clipboard, and
// the byte buffer regarding the image are PNG encoded.
clipboard.Write(clipboard.FmtImage, []byte("image data"))
clipboard.Read(clipboard.FmtImage)
```
Note that read/write regarding image format assumes that the bytes are
PNG encoded since it serves the alpha blending purpose that might be
used in other graphical software.
In addition, `clipboard.Write` returns a channel that can receive an
empty struct as a signal, which indicates the corresponding write call
to the clipboard is outdated, meaning the clipboard has been overwritten
by others and the previously written data is lost. For instance:
```go
changed := clipboard.Write(clipboard.FmtText, []byte("text data"))
select {
case <-changed:
println(`"text data" is no longer available from clipboard.`)
}
```
You can ignore the returning channel if you don't need this type of
notification. Furthermore, when you need more than just knowing whether
clipboard data is changed, use the watcher API:
```go
ch := clipboard.Watch(context.TODO(), clipboard.FmtText)
for data := range ch {
// print out clipboard data whenever it is changed
println(string(data))
}
```
## Demos
- A command line tool `gclip` for command line clipboard accesses, see document [here](./cmd/gclip/README.md).
- A GUI application `gclip-gui` for functionality verifications on mobile systems, see a document [here](./cmd/gclip-gui/README.md).
## Command Usage
`gclip` command offers the ability to interact with the system clipboard
from the shell. To install:
```bash
$ go install golang.design/x/clipboard/cmd/gclip@latest
```
```bash
$ gclip
gclip is a command that provides clipboard interaction.
usage: gclip [-copy|-paste] [-f <file>]
options:
-copy
copy data to clipboard
-f string
source or destination to a given file path
-paste
paste data from clipboard
examples:
gclip -paste paste from clipboard and prints the content
gclip -paste -f x.txt paste from clipboard and save as text to x.txt
gclip -paste -f x.png paste from clipboard and save as image to x.png
cat x.txt | gclip -copy copy content from x.txt to clipboard
gclip -copy -f x.txt copy content from x.txt to clipboard
gclip -copy -f x.png copy x.png as image data to clipboard
```
If `-copy` is used, the command will exit when the data is no longer
available from the clipboard. You can always send the command to the
background using a shell `&` operator, for example:
```bash
$ cat x.txt | gclip -copy &
```
## Platform Specific Details
This package spent efforts to provide cross platform abstraction regarding
accessing system clipboards, but here are a few details you might need to know.
### Dependency
- macOS: require Cgo, no dependency
- Linux: require X11 dev package. For instance, install `libx11-dev` or `xorg-dev` or `libX11-devel` to access X window system.
Wayland sessions are currently unsupported; running under Wayland
typically requires an XWayland bridge and `DISPLAY` to be set.
- Windows: no Cgo, no dependency
- iOS/Android: collaborate with [`gomobile`](https://golang.org/x/mobile)
### Screenshot
In general, when you need test your implementation regarding images,
There are system level shortcuts to put screenshot image into your system clipboard:
- On macOS, use `Ctrl+Shift+Cmd+4`
- On Linux/Ubuntu, use `Ctrl+Shift+PrintScreen`
- On Windows, use `Shift+Win+s`
As described in the API documentation, the package supports read/write
UTF8 encoded plain text or PNG encoded image data. Thus,
the other types of data are not supported yet, i.e. undefined behavior.
## Who is using this package?
The main purpose of building this package is to support the
[midgard](https://changkun.de/s/midgard) project, which offers
clipboard-based features like universal clipboard service that syncs
clipboard content across multiple systems, allocating public accessible
for clipboard content, etc.
To know more projects, check our [wiki](https://github.com/golang-design/clipboard/wiki) page.
## License
MIT | &copy; 2021 The golang.design Initiative Authors, written by [Changkun Ou](https://changkun.de).

View File

@@ -0,0 +1,155 @@
// Copyright 2021 The golang.design Initiative Authors.
// All rights reserved. Use of this source code is governed
// by a MIT license that can be found in the LICENSE file.
//
// Written by Changkun Ou <changkun.de>
/*
Package clipboard provides cross platform clipboard access and supports
macOS/Linux/Windows/Android/iOS platform. Before interacting with the
clipboard, one must call Init to assert if it is possible to use this
package:
err := clipboard.Init()
if err != nil {
panic(err)
}
The most common operations are `Read` and `Write`. To use them:
// write/read text format data of the clipboard, and
// the byte buffer regarding the text are UTF8 encoded.
clipboard.Write(clipboard.FmtText, []byte("text data"))
clipboard.Read(clipboard.FmtText)
// write/read image format data of the clipboard, and
// the byte buffer regarding the image are PNG encoded.
clipboard.Write(clipboard.FmtImage, []byte("image data"))
clipboard.Read(clipboard.FmtImage)
Note that read/write regarding image format assumes that the bytes are
PNG encoded since it serves the alpha blending purpose that might be
used in other graphical software.
In addition, `clipboard.Write` returns a channel that can receive an
empty struct as a signal, which indicates the corresponding write call
to the clipboard is outdated, meaning the clipboard has been overwritten
by others and the previously written data is lost. For instance:
changed := clipboard.Write(clipboard.FmtText, []byte("text data"))
select {
case <-changed:
println(`"text data" is no longer available from clipboard.`)
}
You can ignore the returning channel if you don't need this type of
notification. Furthermore, when you need more than just knowing whether
clipboard data is changed, use the watcher API:
ch := clipboard.Watch(context.TODO(), clipboard.FmtText)
for data := range ch {
// print out clipboard data whenever it is changed
println(string(data))
}
*/
package clipboard // import "golang.design/x/clipboard"
import (
"context"
"errors"
"fmt"
"os"
"sync"
)
var (
// activate only for running tests.
debug = false
errUnavailable = errors.New("clipboard unavailable")
errUnsupported = errors.New("unsupported format")
errNoCgo = errors.New("clipboard: cannot use when CGO_ENABLED=0")
)
// Format represents the format of clipboard data.
type Format int
// All sorts of supported clipboard data
const (
// FmtText indicates plain text clipboard format
FmtText Format = iota
// FmtImage indicates image/png clipboard format
FmtImage
)
var (
// Due to the limitation on operating systems (such as darwin),
// concurrent read can even cause panic, use a global lock to
// guarantee one read at a time.
lock = sync.Mutex{}
initOnce sync.Once
initError error
)
// Init initializes the clipboard package. It returns an error
// if the clipboard is not available to use. This may happen if the
// target system lacks required dependency, such as libx11-dev in X11
// environment. For example,
//
// err := clipboard.Init()
// if err != nil {
// panic(err)
// }
//
// If Init returns an error, any subsequent Read/Write/Watch call
// may result in an unrecoverable panic.
func Init() error {
initOnce.Do(func() {
initError = initialize()
})
return initError
}
// Read returns a chunk of bytes of the clipboard data if it presents
// in the desired format t presents. Otherwise, it returns nil.
func Read(t Format) []byte {
lock.Lock()
defer lock.Unlock()
buf, err := read(t)
if err != nil {
if debug {
fmt.Fprintf(os.Stderr, "read clipboard err: %v\n", err)
}
return nil
}
return buf
}
// Write writes a given buffer to the clipboard in a specified format.
// Write returned a receive-only channel can receive an empty struct
// as a signal, which indicates the clipboard has been overwritten from
// this write.
// If format t indicates an image, then the given buf assumes
// the image data is PNG encoded.
func Write(t Format, buf []byte) <-chan struct{} {
lock.Lock()
defer lock.Unlock()
changed, err := write(t, buf)
if err != nil {
if debug {
fmt.Fprintf(os.Stderr, "write to clipboard err: %v\n", err)
}
return nil
}
return changed
}
// Watch returns a receive-only channel that received the clipboard data
// whenever any change of clipboard data in the desired format happens.
//
// The returned channel will be closed if the given context is canceled.
func Watch(ctx context.Context, t Format) <-chan []byte {
return watch(ctx, t)
}

View File

@@ -0,0 +1,80 @@
// Copyright 2021 The golang.design Initiative Authors.
// All rights reserved. Use of this source code is governed
// by a MIT license that can be found in the LICENSE file.
//
// Written by Changkun Ou <changkun.de>
//go:build android
#include <android/log.h>
#include <jni.h>
#include <stdlib.h>
#include <string.h>
#define LOG_FATAL(...) __android_log_print(ANDROID_LOG_FATAL, \
"GOLANG.DESIGN/X/CLIPBOARD", __VA_ARGS__)
static jmethodID find_method(JNIEnv *env, jclass clazz, const char *name, const char *sig) {
jmethodID m = (*env)->GetMethodID(env, clazz, name, sig);
if (m == 0) {
(*env)->ExceptionClear(env);
LOG_FATAL("cannot find method %s %s", name, sig);
return 0;
}
return m;
}
jobject get_clipboard(uintptr_t jni_env, uintptr_t ctx) {
JNIEnv *env = (JNIEnv*)jni_env;
jclass ctxClass = (*env)->GetObjectClass(env, (jobject)ctx);
jmethodID getSystemService = find_method(env, ctxClass, "getSystemService", "(Ljava/lang/String;)Ljava/lang/Object;");
jstring service = (*env)->NewStringUTF(env, "clipboard");
jobject ret = (jobject)(*env)->CallObjectMethod(env, (jobject)ctx, getSystemService, service);
jthrowable err = (*env)->ExceptionOccurred(env);
if (err != NULL) {
LOG_FATAL("cannot find clipboard");
(*env)->ExceptionClear(env);
return NULL;
}
return ret;
}
char *clipboard_read_string(uintptr_t java_vm, uintptr_t jni_env, uintptr_t ctx) {
JNIEnv *env = (JNIEnv*)jni_env;
jobject mgr = get_clipboard(jni_env, ctx);
if (mgr == NULL) {
return NULL;
}
jclass mgrClass = (*env)->GetObjectClass(env, mgr);
jmethodID getText = find_method(env, mgrClass, "getText", "()Ljava/lang/CharSequence;");
jobject content = (jstring)(*env)->CallObjectMethod(env, mgr, getText);
if (content == NULL) {
return NULL;
}
jclass clzCharSequence = (*env)->GetObjectClass(env, content);
jmethodID toString = (*env)->GetMethodID(env, clzCharSequence, "toString", "()Ljava/lang/String;");
jobject s = (*env)->CallObjectMethod(env, content, toString);
const char *chars = (*env)->GetStringUTFChars(env, s, NULL);
char *copy = strdup(chars);
(*env)->ReleaseStringUTFChars(env, s, chars);
return copy;
}
void clipboard_write_string(uintptr_t java_vm, uintptr_t jni_env, uintptr_t ctx, char *str) {
JNIEnv *env = (JNIEnv*)jni_env;
jobject mgr = get_clipboard(jni_env, ctx);
if (mgr == NULL) {
return;
}
jclass mgrClass = (*env)->GetObjectClass(env, mgr);
jmethodID setText = find_method(env, mgrClass, "setText", "(Ljava/lang/CharSequence;)V");
(*env)->CallVoidMethod(env, mgr, setText, (*env)->NewStringUTF(env, str));
}

View File

@@ -0,0 +1,102 @@
// Copyright 2021 The golang.design Initiative Authors.
// All rights reserved. Use of this source code is governed
// by a MIT license that can be found in the LICENSE file.
//
// Written by Changkun Ou <changkun.de>
//go:build android
package clipboard
/*
#cgo LDFLAGS: -landroid -llog
#include <stdlib.h>
char *clipboard_read_string(uintptr_t java_vm, uintptr_t jni_env, uintptr_t ctx);
void clipboard_write_string(uintptr_t java_vm, uintptr_t jni_env, uintptr_t ctx, char *str);
*/
import "C"
import (
"bytes"
"context"
"time"
"unsafe"
"golang.org/x/mobile/app"
)
func initialize() error { return nil }
func read(t Format) (buf []byte, err error) {
switch t {
case FmtText:
s := ""
if err := app.RunOnJVM(func(vm, env, ctx uintptr) error {
cs := C.clipboard_read_string(C.uintptr_t(vm), C.uintptr_t(env), C.uintptr_t(ctx))
if cs == nil {
return nil
}
s = C.GoString(cs)
C.free(unsafe.Pointer(cs))
return nil
}); err != nil {
return nil, err
}
return []byte(s), nil
case FmtImage:
return nil, errUnsupported
default:
return nil, errUnsupported
}
}
// write writes the given data to clipboard and
// returns true if success or false if failed.
func write(t Format, buf []byte) (<-chan struct{}, error) {
done := make(chan struct{}, 1)
switch t {
case FmtText:
cs := C.CString(string(buf))
defer C.free(unsafe.Pointer(cs))
if err := app.RunOnJVM(func(vm, env, ctx uintptr) error {
C.clipboard_write_string(C.uintptr_t(vm), C.uintptr_t(env), C.uintptr_t(ctx), cs)
done <- struct{}{}
return nil
}); err != nil {
return nil, err
}
return done, nil
case FmtImage:
return nil, errUnsupported
default:
return nil, errUnsupported
}
}
func watch(ctx context.Context, t Format) <-chan []byte {
recv := make(chan []byte, 1)
ti := time.NewTicker(time.Second)
last := Read(t)
go func() {
for {
select {
case <-ctx.Done():
close(recv)
return
case <-ti.C:
b := Read(t)
if b == nil {
continue
}
if bytes.Compare(last, b) != 0 {
recv <- b
last = b
}
}
}
}()
return recv
}

View File

@@ -0,0 +1,266 @@
// Copyright 2021 The golang.design Initiative Authors.
// All rights reserved. Use of this source code is governed
// by a MIT license that can be found in the LICENSE file.
//
// Written by Changkun Ou <changkun.de>
//go:build darwin && !ios
package clipboard
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"strconv"
"strings"
"sync"
"time"
)
var (
lastChangeCount int64
changeCountMu sync.Mutex
)
func initialize() error { return nil }
func read(t Format) (buf []byte, err error) {
switch t {
case FmtText:
return readText()
case FmtImage:
return readImage()
default:
return nil, errUnsupported
}
}
func readText() ([]byte, error) {
// Check if clipboard contains string data
checkScript := `
try
set clipboardTypes to (clipboard info)
repeat with aType in clipboardTypes
if (first item of aType) is string then
return "hastext"
end if
end repeat
return "notext"
on error
return "error"
end try
`
cmd := exec.Command("osascript", "-e", checkScript)
checkOut, err := cmd.Output()
if err != nil {
return nil, errUnavailable
}
checkOut = bytes.TrimSpace(checkOut)
if !bytes.Equal(checkOut, []byte("hastext")) {
return nil, errUnavailable
}
// Now get the actual text
cmd = exec.Command("osascript", "-e", "get the clipboard")
out, err := cmd.Output()
if err != nil {
return nil, errUnavailable
}
// Remove trailing newline that osascript adds
out = bytes.TrimSuffix(out, []byte("\n"))
// If clipboard was set to empty string, return nil
if len(out) == 0 {
return nil, nil
}
return out, nil
}
func readImage() ([]byte, error) {
// AppleScript to read image data from clipboard as base64
script := `
try
set theData to the clipboard as «class PNGf»
return theData
on error
return ""
end try
`
cmd := exec.Command("osascript", "-e", script)
out, err := cmd.Output()
if err != nil {
return nil, errUnavailable
}
// Check if we got any data
out = bytes.TrimSpace(out)
if len(out) == 0 {
return nil, errUnavailable
}
// The output is in hex format (e.g., «data PNGf89504E...»)
// We need to extract and convert it
outStr := string(out)
if !strings.HasPrefix(outStr, "«data PNGf") || !strings.HasSuffix(outStr, "»") {
return nil, errUnavailable
}
// Extract hex data
hexData := strings.TrimPrefix(outStr, "«data PNGf")
hexData = strings.TrimSuffix(hexData, "»")
// Convert hex to bytes
buf := make([]byte, len(hexData)/2)
for i := 0; i < len(hexData); i += 2 {
b, err := strconv.ParseUint(hexData[i:i+2], 16, 8)
if err != nil {
return nil, errUnavailable
}
buf[i/2] = byte(b)
}
return buf, nil
}
// write writes the given data to clipboard and
// returns true if success or false if failed.
func write(t Format, buf []byte) (<-chan struct{}, error) {
var err error
switch t {
case FmtText:
err = writeText(buf)
case FmtImage:
err = writeImage(buf)
default:
return nil, errUnsupported
}
if err != nil {
return nil, err
}
// Update change count
changeCountMu.Lock()
lastChangeCount++
currentCount := lastChangeCount
changeCountMu.Unlock()
// use unbuffered channel to prevent goroutine leak
changed := make(chan struct{}, 1)
go func() {
for {
time.Sleep(time.Second)
changeCountMu.Lock()
if lastChangeCount != currentCount {
changeCountMu.Unlock()
changed <- struct{}{}
close(changed)
return
}
changeCountMu.Unlock()
}
}()
return changed, nil
}
func writeText(buf []byte) error {
if len(buf) == 0 {
// Clear clipboard
script := `set the clipboard to ""`
cmd := exec.Command("osascript", "-e", script)
if err := cmd.Run(); err != nil {
return errUnavailable
}
return nil
}
// Escape the text for AppleScript
text := string(buf)
text = strings.ReplaceAll(text, "\\", "\\\\")
text = strings.ReplaceAll(text, "\"", "\\\"")
script := fmt.Sprintf(`set the clipboard to "%s"`, text)
cmd := exec.Command("osascript", "-e", script)
if err := cmd.Run(); err != nil {
return errUnavailable
}
return nil
}
func writeImage(buf []byte) error {
if len(buf) == 0 {
// Clear clipboard
script := `set the clipboard to ""`
cmd := exec.Command("osascript", "-e", script)
if err := cmd.Run(); err != nil {
return errUnavailable
}
return nil
}
// Create a temporary file to store the PNG data
tmpFile, err := os.CreateTemp("", "clipboard*.png")
if err != nil {
return errUnavailable
}
defer os.Remove(tmpFile.Name())
if _, err := tmpFile.Write(buf); err != nil {
tmpFile.Close()
return errUnavailable
}
tmpFile.Close()
// Use osascript to set clipboard to the image file
script := fmt.Sprintf(`
set theFile to POSIX file "%s"
set theImage to read theFile as «class PNGf»
set the clipboard to theImage
`, tmpFile.Name())
cmd := exec.Command("osascript", "-e", script)
if err := cmd.Run(); err != nil {
return errUnavailable
}
return nil
}
func watch(ctx context.Context, t Format) <-chan []byte {
recv := make(chan []byte, 1)
ti := time.NewTicker(time.Second)
// Get initial clipboard content
var lastContent []byte
if b := Read(t); b != nil {
lastContent = make([]byte, len(b))
copy(lastContent, b)
}
go func() {
defer close(recv)
defer ti.Stop()
for {
select {
case <-ctx.Done():
return
case <-ti.C:
b := Read(t)
if b == nil {
continue
}
// Check if content changed
if !bytes.Equal(lastContent, b) {
recv <- b
lastContent = make([]byte, len(b))
copy(lastContent, b)
}
}
}
}()
return recv
}

View File

@@ -0,0 +1,80 @@
// Copyright 2021 The golang.design Initiative Authors.
// All rights reserved. Use of this source code is governed
// by a MIT license that can be found in the LICENSE file.
//
// Written by Changkun Ou <changkun.de>
//go:build ios
package clipboard
/*
#cgo CFLAGS: -x objective-c
#cgo LDFLAGS: -framework Foundation -framework UIKit -framework MobileCoreServices
#import <stdlib.h>
void clipboard_write_string(char *s);
char *clipboard_read_string();
*/
import "C"
import (
"bytes"
"context"
"time"
"unsafe"
)
func initialize() error { return nil }
func read(t Format) (buf []byte, err error) {
switch t {
case FmtText:
return []byte(C.GoString(C.clipboard_read_string())), nil
case FmtImage:
return nil, errUnsupported
default:
return nil, errUnsupported
}
}
// SetContent sets the clipboard content for iOS
func write(t Format, buf []byte) (<-chan struct{}, error) {
done := make(chan struct{}, 1)
switch t {
case FmtText:
cs := C.CString(string(buf))
defer C.free(unsafe.Pointer(cs))
C.clipboard_write_string(cs)
return done, nil
case FmtImage:
return nil, errUnsupported
default:
return nil, errUnsupported
}
}
func watch(ctx context.Context, t Format) <-chan []byte {
recv := make(chan []byte, 1)
ti := time.NewTicker(time.Second)
last := Read(t)
go func() {
for {
select {
case <-ctx.Done():
close(recv)
return
case <-ti.C:
b := Read(t)
if b == nil {
continue
}
if bytes.Compare(last, b) != 0 {
recv <- b
last = b
}
}
}
}()
return recv
}

View File

@@ -0,0 +1,20 @@
// Copyright 2021 The golang.design Initiative Authors.
// All rights reserved. Use of this source code is governed
// by a MIT license that can be found in the LICENSE file.
//
// Written by Changkun Ou <changkun.de>
//go:build ios
#import <UIKit/UIKit.h>
#import <MobileCoreServices/MobileCoreServices.h>
void clipboard_write_string(char *s) {
NSString *value = [NSString stringWithUTF8String:s];
[[UIPasteboard generalPasteboard] setString:value];
}
char *clipboard_read_string() {
NSString *str = [[UIPasteboard generalPasteboard] string];
return (char *)[str UTF8String];
}

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