Compare commits

..
271 Commits
Author SHA1 Message Date
opencode 7b62c10553 release: v0.6.1 2025-09-01 22:07:53 +00:00
Dax Raad 61c7196bd9 catch migration failures 2025-09-01 18:00:40 -04:00
opencode 365fdd9ff8 release: v0.6.0 2025-09-01 21:43:13 +00:00
Dax Raad f6bc9238df docs: sdk 2025-09-01 17:35:52 -04:00
Aiden ClineandGitHub 26f75d4e68 fix: tui attachment bound (#2361) 2025-09-01 16:33:36 -05:00
Jay V 8ba8d3c7e3 docs: update email 2025-09-01 17:30:32 -04:00
DaxandGitHub f993541e0b Refactor to support multiple instances inside single opencode process (#2360)
This release has a bunch of minor breaking changes if you are using opencode plugins or sdk

1. storage events have been removed (we might bring this back but had some issues)
2. concept of `app` is gone - there is a new concept called `project` and endpoints to list projects and get the current project
3. plugin receives `directory` which is cwd and `worktree` which is where the root of the project is if it's a git repo
4. the session.chat function has been renamed to session.prompt in sdk. it no longer requires model to be passed in (model is now an object)
5. every endpoint takes an optional `directory` parameter to operate as though opencode is running in that directory
2025-09-01 17:15:49 -04:00
Aiden ClineandGitHub e2df3eb44d add --command to opencode run (#2348) 2025-09-01 14:19:18 -05:00
Dax Raad 38f9ce05f6 wip: cloud 2025-09-01 11:53:43 -04:00
Dax Raad a6e09363b8 wip: cloud 2025-09-01 11:47:58 -04:00
GitHub Action 49629bb58e ignore: update download stats 2025-09-01 2025-09-01 12:04:32 +00:00
Dax Raad 2bb5b9b13a wip: cloud 2025-09-01 04:03:07 -04:00
Dax Raad 41338d1bf9 wip: cloud 2025-09-01 03:55:48 -04:00
Dax Raad 41ee9c94c7 wip: cloud 2025-09-01 03:53:49 -04:00
Dax Raad 9c16db0f36 wip: cloud 2025-09-01 03:51:45 -04:00
Dax Raad 721869353b wip: sync 2025-09-01 03:15:38 -04:00
Dax Raad 6d22ade771 wip: cloud 2025-09-01 03:13:05 -04:00
Dax Raad fbcceeb781 wip: cloud 2025-09-01 03:10:40 -04:00
Dax Raad 95775d68b7 wip: cloud 2025-09-01 03:04:49 -04:00
Dax Raad cf11669618 wip: cloud 2025-09-01 03:04:07 -04:00
Dax Raad 65dc19e85a wip: cloud 2025-09-01 02:57:47 -04:00
Dax Raad cfcfceca6d wip: cloud 2025-09-01 02:55:10 -04:00
Dax Raad 9f8899a9f9 wip: cloud 2025-09-01 02:28:21 -04:00
Dax Raad 449a063fe2 wip: cloud 2025-09-01 02:21:36 -04:00
Régis BlancandGitHub 37530359ee fix: ensure gopls lsp id matches docs (#2344) 2025-08-31 21:52:08 -05:00
Aiden ClineandGitHub 65f0bea146 ignore: better error logging (#2346) 2025-08-31 17:11:04 -05:00
Beshoy GirgisandGitHub e4cc05a975 feat: Allow provider timeout override (#1982) 2025-08-31 14:06:02 -04:00
Aiden ClineandGitHub 029612d8d5 fix: ensure shell cmds can be properly aborted (#2339) 2025-08-31 12:48:30 -05:00
Aiden ClineandGitHub e9826e8a22 fix: adjust title generation prompt to prevent direct response instead of title gen (#2338) 2025-08-31 11:01:19 -05:00
GitHub Action ad5f209dc8 ignore: update download stats 2025-08-31 2025-08-31 12:04:06 +00:00
fcfeac57c5 fix: resolve virtual envs for python LSP (#2155)
Co-authored-by: rekram1-node <aidenpcline@gmail.com>
2025-08-30 23:53:03 -05:00
Aiden ClineandGitHub 2946898934 fix: ensure command uses currently selected model (#2336) 2025-08-30 15:41:06 -05:00
Aiden ClineandGitHub b4d95545e0 add support for lsp workspace/didChangeConfiguration (#2334) 2025-08-30 14:49:13 -05:00
Jay V d3bbaa141c ignore: cloud 2025-08-30 15:28:35 -04:00
Jay V 8714f23509 ignore: cloud styles 2025-08-30 15:27:46 -04:00
Dax Raad c676f12306 wip: cloud 2025-08-30 15:20:51 -04:00
Aiden ClineandGitHub dac821229e fix: resolve [pasted lines] when passing paste as arguments for command (#2333) 2025-08-30 10:56:00 -05:00
Aiden ClineandGitHub 3625766ad4 tweak: ensure run command doesn't send request if no prompt present (#2332) 2025-08-30 10:39:28 -05:00
Roderik van der VeerandGitHub 924e84b0de fix: change command selection to prefer exact matches over fuzzy sear… (#2314) 2025-08-30 09:44:27 -05:00
GitHub Action 70db3cffb0 ignore: update download stats 2025-08-30 2025-08-30 12:03:53 +00:00
Antonandopencode 0c30a6f303 Use a single rust LSP server instance for entire cargo workspace (#2292) 2025-08-30 06:00:39 +00:00
opencode 0c7a887dbc release: v0.5.29 2025-08-30 06:00:39 +00:00
Dax Raad 48e01cfee7 ignore: sync 2025-08-30 01:36:25 -04:00
Dax Raad b54aa65f5f ignore: fix stuff 2025-08-30 01:19:03 -04:00
Dax Raad 52b3eddeee ignore: fix dev remote 2025-08-30 01:06:48 -04:00
Dax Raad f821b55514 ignore: cloud resource 2025-08-30 00:58:22 -04:00
Frank 37f284f9a9 wip: cloud 2025-08-29 23:32:17 -04:00
Frank 0178eab29b wip cloud 2025-08-29 23:02:27 -04:00
Aiden ClineandGitHub a3f4a030b4 fix: mcp tool not triggering hooks (#2320) 2025-08-29 21:51:06 -05:00
Jay V 9a330b4f0f ignore: cloud keys section 2025-08-29 20:04:57 -04:00
Dax Raad 25e53e090b ignore: create key for new workspace 2025-08-29 19:56:29 -04:00
Frank 46927ee9a5 wip: cloud 2025-08-29 19:45:17 -04:00
Frank c3a25eff78 wip: cloud 2025-08-29 19:34:58 -04:00
Jay V b40c02e258 ignore: cloud balance section 2025-08-29 19:20:18 -04:00
Jay V 058163333d ignore: cloud payment history 2025-08-29 19:20:18 -04:00
Jay V 28c341ad32 ignore: cloud usage history 2025-08-29 19:20:18 -04:00
Jay V a05e677412 ignore: cloud progress 2025-08-29 19:20:18 -04:00
ParbezandGitHub 918dd58a15 Fix code block formatting in sdk.mdx (#2312) 2025-08-29 14:29:18 -05:00
Jay V 9c02c4cfe8 ignore: cloud 2025-08-29 12:48:01 -04:00
Frank fd355c15db Update sst 2025-08-29 12:26:03 -04:00
Aiden ClineandGitHub 12eb1391b9 fix: lsp debug cmd log (#2310) 2025-08-29 11:11:26 -05:00
Dax Raad 4496cd4b64 ignore: cloud solid fixes 2025-08-29 11:58:17 -04:00
Aiden ClineandGitHub 7f5e5fccc8 ignore: add error log for title gen failures (#2309) 2025-08-29 10:53:58 -05:00
Aiden ClineandGitHub 1a5b456bb6 fix: add additional encouragement for title gen (#2298) 2025-08-29 09:47:08 -05:00
GitHub Action b55231c106 ignore: update download stats 2025-08-29 2025-08-29 12:04:20 +00:00
Aiden ClineandGitHub d7a9f343c5 tui: show actual error if command fails (#2296) 2025-08-28 18:42:55 -05:00
Adam 5ecd7fdd0c chore: remove unused dep 2025-08-28 18:16:38 -05:00
Adam 1aaf8f11cf chore: update gitignore 2025-08-28 18:16:05 -05:00
7fab12da28 fix: replace isomorphic-git status with direct git diff for worktree support (#1706)
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
2025-08-28 18:15:26 -05:00
Aiden ClineandGitHub 6daf0fdb2b allow slash commands to resolve ~/ references (#2295) 2025-08-28 17:31:03 -05:00
Jay V f2f4d87cc0 ignore: cloud styles 2025-08-28 18:13:51 -04:00
Frank 8a0e773add wip cloud 2025-08-28 17:52:20 -04:00
Jay V 9b27d61fe8 ignore: cloud 2025-08-28 17:37:48 -04:00
Dax Raad 7d1eb010c1 ignore: cloud 2025-08-28 17:35:54 -04:00
Dax Raad 3fa02623c3 ignore: cloud 2025-08-28 17:33:52 -04:00
Jay V 403f9b2f1b ignore: cloud 2025-08-28 17:17:00 -04:00
Jay V 4d81f90dde ignore: cloud 2025-08-28 17:10:41 -04:00
Jay V 36ec9dddb2 ignore: cloud 2025-08-28 17:06:37 -04:00
Frank 5a0e7698e1 wip cloud 2025-08-28 17:05:51 -04:00
Frank c6ef92634d wip cloud 2025-08-28 16:44:55 -04:00
Jay V f97fdceb01 ignore: cloud 2025-08-28 16:29:21 -04:00
Jay V 3f225e3248 ignore: cloud 2025-08-28 16:21:09 -04:00
Jay V 151ff05381 ignore: cloud styles 2025-08-28 16:21:09 -04:00
Adam e37e878e72 feat: home dir in app info 2025-08-28 14:34:20 -05:00
Jay V 3de1ce467f ignore: cloud 2025-08-28 14:18:37 -04:00
Jay V eff50c0aab ignore: cloud 2025-08-28 14:12:31 -04:00
Dax Raad 02e014b0a0 ignore: cloud 2025-08-28 14:09:51 -04:00
Jakub KopeckýandGitHub a928a35c96 fix: mcp client name (#2289) 2025-08-28 12:48:29 -05:00
Ethan SheaandGitHub 555202f3b1 Vercel AI Gateway key deeplinks into the dashboard (#2287) 2025-08-28 11:06:45 -05:00
Aiden ClineandGitHub 37cf262094 fix: tui not showing err toasts (#2290) 2025-08-28 10:55:47 -05:00
Adam aa9ab0a304 feat: include ignored files 2025-08-28 10:49:45 -05:00
GitHub Action 4331d77b9e ignore: update download stats 2025-08-28 2025-08-28 12:04:29 +00:00
Dax Raad cf79262dc4 ignore: cloud 2025-08-27 21:49:39 -04:00
Dax Raad 43e8047ad6 ignore: cloud 2025-08-27 21:49:04 -04:00
Dax Raad 63c7c921ed ignore: cloud 2025-08-27 21:47:45 -04:00
Jay V bce1398b73 ignore: cloud 2025-08-27 19:36:44 -04:00
Aiden ClineandGitHub 87cf08a9e7 docs: add copy button to user messages too (#2285) 2025-08-27 18:14:27 -05:00
Aiden ClineandGitHub ad8ea82611 add synthetic user message before bash execution (when using !) (#2283) 2025-08-27 17:41:24 -05:00
Jay V d984dbd876 ignore: cloud 2025-08-27 17:53:15 -04:00
Aiden ClineandGitHub 2d794ed03d fix: ensure / commands dont try to resolve @ references from cmd outputs (#2282) 2025-08-27 15:59:33 -05:00
Adam 8749c0c707 feat: file list api 2025-08-27 15:28:03 -05:00
Jay V 3359417378 ignore: cloud 2025-08-27 16:02:32 -04:00
Aiden ClineandGitHub 8381760b27 docs: fix client.event.subscribe example (#2280) 2025-08-27 11:42:09 -05:00
Dax Raad 0fbd7c84fd sdk update 2025-08-27 12:18:09 -04:00
Aiden ClineandGitHub 5c17ee52c5 docs: document anthropic thinking budgets (#2243) 2025-08-27 09:41:51 -05:00
GitHub Action 3606775b79 ignore: update download stats 2025-08-27 2025-08-27 12:04:25 +00:00
spoons-and-mirrorsandGitHub 6233251fc0 fix: shimmer cpu cost (#2084) 2025-08-27 06:18:26 -05:00
Jay V 587b8ae7ee docs: edit 2025-08-26 17:30:43 -04:00
StibbsandGitHub 877855d1ee docs: mcp access mgmt instructions (#2087) 2025-08-26 17:27:44 -04:00
opencode eebca580e3 release: v0.5.28 2025-08-26 20:23:34 +00:00
Frank e73a7c23d0 Revert "fix(tui): too early"
This reverts commit 564418f1ff.
2025-08-26 16:13:16 -04:00
Jay V 11de2e59f3 docs: edit commands 2025-08-26 16:10:53 -04:00
Jay V f4b69df7a3 docs: updating config schema 2025-08-26 16:10:53 -04:00
Jay V 83b9b67c4c docs: adding new provider 2025-08-26 16:10:53 -04:00
Aiden ClineandGitHub d9de78cfe8 fix: bash tool description (#2260) 2025-08-26 13:42:01 -05:00
GitHub Action ef6bff6386 ignore: update download stats 2025-08-26 2025-08-26 12:04:26 +00:00
Aiden ClineandGitHub cb03655aac fix: eslint ENOTEMPTY (#2252) 2025-08-25 23:11:38 -05:00
Timo ClasenandGitHub 012a292948 fix: model flag in non interactive mode (#2249) 2025-08-25 15:06:54 -05:00
Frank d2e2eae4b8 Add opencode workflow 2025-08-26 01:46:16 +08:00
Frank fd84e8d405 Add opencode workflow 2025-08-26 01:40:52 +08:00
opencode 567a1964c0 release: v0.5.27 2025-08-25 17:10:18 +00:00
adamdotdevin 564418f1ff fix(tui): too early 2025-08-25 12:04:49 -05:00
opencode d7c4faec58 release: v0.5.26 2025-08-25 16:54:15 +00:00
adamdotdevinandopencode 34982b5d18 fix(tui): wording 2025-08-25 16:38:25 +00:00
opencode 5b5bd146ea release: v0.5.25 2025-08-25 16:38:24 +00:00
adamdotdevin 836c2060c7 fix(tui): sort custom commands lower 2025-08-25 11:32:15 -05:00
adamdotdevin 6357136ca5 fix(tui): sort custom commands lower 2025-08-25 11:29:56 -05:00
adamdotdevin 0a0b363587 feat(tui): grok free 2025-08-25 11:27:58 -05:00
Jay V f5f6167146 docs: edit 2025-08-25 12:11:02 -04:00
adamdotdevin f1684c9e15 fix(tui): fix logo color 2025-08-25 10:08:52 -05:00
adamdotdevin f597b7287b chore: cleanup 2025-08-25 10:08:10 -05:00
GitHub Action 95fabc1407 ignore: update download stats 2025-08-25 2025-08-25 12:04:21 +00:00
Aiden ClineandGitHub 315c366e11 docs: fix shell examples (#2236) 2025-08-24 23:53:39 -05:00
opencode 5d68a7c2e0 release: v0.5.24 2025-08-24 23:01:00 +00:00
Dax Raad 1b2d3bf659 ci: tweak 2025-08-24 18:55:44 -04:00
opencode 24e4f5b051 release: v0.5.23 2025-08-24 22:53:37 +00:00
Dax Raad 2992c5a6bf ci: retry clone 2025-08-24 18:48:03 -04:00
Dax Raad ca2660ccf8 ci: ignore 2025-08-24 18:31:44 -04:00
Frank 6b4bd590ac Add opencode workflow 2025-08-25 06:16:21 +08:00
Frank 60ba42af15 Add opencode workflow 2025-08-25 06:14:47 +08:00
Frank f22827bdfa Add opencode workflow 2025-08-25 03:39:11 +08:00
Frank f9b5b6d129 Add opencode workflow 2025-08-25 03:38:02 +08:00
Aiden ClineandGitHub cc66e06101 fix: command model selection (#2219) 2025-08-24 12:06:48 -05:00
GitHub Action d4c8d95ec6 ignore: update download stats 2025-08-24 2025-08-24 12:03:59 +00:00
Aiden ClineandGitHub 0fd312346b docs: fix plan agent docs (#2215) 2025-08-23 14:52:02 -05:00
b80046120c docs: document editor --wait flag (#2209)
Co-authored-by: rekram1-node <aidenpcline@gmail.com>
2025-08-23 14:43:20 -05:00
Aiden ClineandGitHub 07ed2a8391 docs: document out of box lsps (#2213) 2025-08-23 14:22:22 -05:00
opencode e9f52934e9 release: v0.5.18 2025-08-23 16:27:02 +00:00
Dax Raad 732b67f8ce ci: stuff 2025-08-23 12:21:58 -04:00
Dax Raad d47bb96784 ci: ignore 2025-08-23 12:10:08 -04:00
JohnnyandGitHub 6456350564 docs: fix nodejs installation commands (#2193) 2025-08-23 08:23:24 -05:00
GitHub Action c5c0a2ca6e ignore: update download stats 2025-08-23 2025-08-23 12:03:48 +00:00
3706b2bca7 feat(lsp): option to disable lsps installing automatically (#1997)
Co-authored-by: rekram1-node <aidenpcline@gmail.com>
2025-08-22 22:39:19 -05:00
Aiden ClineandGitHub 1f57b9a70f fix: count reasoning tokens (#2187) 2025-08-22 18:21:39 -05:00
Aiden ClineandGitHub 004f53f741 ignore: update json schema for better lsp dx (#2186) 2025-08-22 17:59:18 -05:00
Jay V cf29ec0a59 docs: edit 2025-08-22 18:36:17 -04:00
Jay V b5e08acdf7 docs: update 2025-08-22 18:34:35 -04:00
Dax Raad 7ddeeeb4f8 ignore: typecheck 2025-08-22 18:31:51 -04:00
Dax Raad 0f1697b2ab add sse streaming to sdk 2025-08-22 18:30:25 -04:00
LubosandGitHub 6e626afdcb chore(openapi): set correct content type for server-sent events (#2045) 2025-08-22 17:51:24 -04:00
Dax Raad 0fe94c1616 docs: add file names to code block titles in commands.mdx 2025-08-22 17:23:59 -04:00
Dax Raad a42b004c72 docs: add commands page to sidebar 2025-08-22 17:23:59 -04:00
opencode 35f57768fd release: v0.5.15 2025-08-22 21:16:23 +00:00
Aiden ClineandGitHub 9a90ce84fb fix: format error log (#2184) 2025-08-22 16:09:15 -05:00
133fe41cd5 slash commands (#2157)
Co-authored-by: adamdotdevin <2363879+adamdottv@users.noreply.github.com>
2025-08-22 17:04:28 -04:00
Jay V 74c1085103 docs: edit 2025-08-22 15:14:02 -04:00
Jay V 497fc170fd docs: edit 2025-08-22 13:54:56 -04:00
Aiden ClineandGitHub 3edab60560 docs: remove fake model (#2175) 2025-08-22 11:55:11 -05:00
DaxandGitHub 3f2ac2b9b0 Update duplicate-issues.yml 2025-08-22 08:52:39 -04:00
GitHub Action 1577b44087 ignore: update download stats 2025-08-22 2025-08-22 12:04:13 +00:00
Thai Nguyen HungandGitHub 39f52f48f2 fix: correct typo in LSP documentation (#2164) 2025-08-22 06:43:24 -05:00
Aiden ClineandGitHub 4fadbcfb90 fix: error logging (#2165) 2025-08-21 23:27:49 -05:00
Dax Raad 08c5c401ba deal with non existing cache folder 2025-08-21 22:58:39 -04:00
Aiden ClineandGitHub ba2e86c7ef tweak: adjust plan agent to ask when running bash, give it edit tooli… (#2150) 2025-08-21 18:25:31 -04:00
Dax Raad 6d056789c7 ignore: docs agent 2025-08-21 17:15:43 -04:00
Dax Raad 5d508cc9c2 docs: update SDK documentation 2025-08-21 17:15:21 -04:00
Dax Raad d9233872b9 add createOpencodeServer to js sdk and wait for readiness. always use random port for opencode serve. add /client and /server imports for js sdk 2025-08-21 17:13:24 -04:00
Aiden ClineandGitHub aa4dba1541 fix: if lsp fails to spawn it shouldn't inject errors into edit diagnostics (#2145) 2025-08-21 12:06:32 -05:00
Dax Raad 947a3e8aff fix sdk config type 2025-08-21 13:00:16 -04:00
Dax Raad 9a3186317b allow importing sdk from @opencode-ai/sdk/server and @opencode-ai/sdk/client 2025-08-21 12:58:37 -04:00
b1e584ca1d chore: add export types in js-sdk (#1923)
Co-authored-by: zwingzheng <zwingzheng@tencent.com>
2025-08-21 11:06:27 -05:00
bca523eb63 fix(js-sdk): fix types in session.chat (#1925)
Co-authored-by: zwingzheng <zwingzheng@tencent.com>
Co-authored-by: rekram1-node <aidenpcline@gmail.com>
2025-08-21 10:44:20 -05:00
Denys Pavlovandopencode 2ff4cd2c2b fix: preserve cache dir on cleanup (#2126) 2025-08-21 15:27:25 +00:00
Dax Raadandopencode d686269377 await config hooks 2025-08-21 15:27:25 +00:00
opencode 491abd6b5b release: v0.5.13 2025-08-21 15:27:25 +00:00
Dax Raad 4518f96e3d add plugin hook for config 2025-08-21 11:22:24 -04:00
GitHub Action a9dcbedf99 ignore: update download stats 2025-08-21 2025-08-21 12:04:21 +00:00
Aiden ClineandGitHub 9231043eb4 tweak: adjust plan -> build transition prompt (#2111) 2025-08-21 06:52:38 -05:00
Frank 04dcd87170 fix do migration 2025-08-21 18:02:32 +08:00
Frank c31fd9ed79 fix do migration 2025-08-21 17:52:55 +08:00
Jay V 2989d92794 docs: update 2025-08-20 17:58:56 -04:00
Jay V 256d074411 docs: gitlab 2025-08-20 17:51:16 -04:00
Jay V 8b01676ec0 docs: edit 2025-08-20 17:39:37 -04:00
Lee TickettandGitHub 34c6c8494a docs: Add GitLab CLI agent integration doc (#2103) 2025-08-20 17:37:43 -04:00
Dax Raad 522bed6b7d ignore: cloud stuff 2025-08-20 17:01:18 -04:00
Vincent BernatandGitHub dda672284c fix: ignore case when checking Qwen in model ID for todos (#2122) 2025-08-20 14:44:27 -05:00
Jay Vandopencode 6018364164 docs: edit 2025-08-20 18:22:48 +00:00
opencode bc0d438cee release: v0.5.12 2025-08-20 18:22:48 +00:00
Jay V abef91c223 docs: edit server 2025-08-20 14:13:02 -04:00
Dax Raad 1bbf6d38e5 ci: turn back on aur 2025-08-20 12:46:17 -04:00
opencode c9c9db1e8d release: v0.5.11 2025-08-20 16:36:05 +00:00
Dax Raad b11fe9fbc6 ignore: remove import 2025-08-20 12:29:24 -04:00
Dax Raad 60f3d413de remove auto browser open for now 2025-08-20 12:28:00 -04:00
opencode 1df2d78b85 release: v0.5.10 2025-08-20 16:12:00 +00:00
opencode 2286a872c1 release: v0.5.9 2025-08-20 15:51:24 +00:00
Dax Raad 8a83301e0d copilot auth update version 2025-08-20 11:46:14 -04:00
GitHub Action 9bc40f00e3 ignore: update download stats 2025-08-20 2025-08-20 12:04:23 +00:00
opencode c3c440948a release: v0.5.8 2025-08-20 05:08:31 +00:00
Dax Raad aa10f8a7f6 sonic model 2025-08-20 01:02:41 -04:00
Aiden ClineandGitHub a2db58f125 fix: don't let --continue access subagent session (#2091) 2025-08-19 22:40:07 -05:00
Aiden ClineandGitHub 574be9febf fix: keybind panic (#2092) 2025-08-19 22:39:59 -05:00
Aiden ClineandGitHub 5b05ede748 fix: agent casing issue (#2081) 2025-08-19 18:08:56 -05:00
Aiden ClineandGitHub 4032426185 docs: remove non existent keybind (#2080) 2025-08-19 17:39:02 -05:00
Jay V 8d8045ff95 docs: add sdk doc 2025-08-19 18:11:36 -04:00
Jay V b3c8bec019 docs: edit server 2025-08-19 17:21:45 -04:00
Aiden ClineandGitHub 25f43adaa0 tweak: notify agent it is in build mode when switching from plan mode (#2065) 2025-08-19 15:32:31 -05:00
Timo ClasenandGitHub 4913ee6afd fix(TUI): make it less shimmer (#2076) 2025-08-19 15:30:54 -05:00
c59ded82b3 docs: document server API endpoints (#2019)
Co-authored-by: Jay <air@live.ca>
2025-08-19 16:13:02 -04:00
Aiden ClineandGitHub 40bdbf92a3 fix: tui panic from logger (#2075) 2025-08-19 14:47:44 -05:00
Aiden ClineGitHubopencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
ad76d7e57d fix: add type checking for MCP tool path parameters (#2073)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2025-08-19 13:38:33 -05:00
GitHub Action 863ae6fa7d ignore: update download stats 2025-08-19 2025-08-19 12:04:15 +00:00
Aiden ClineandGitHub 8f230ad4b4 fix: interface conversion panic (#2060) 2025-08-19 05:25:46 -05:00
Aiden ClineandGitHub c0f90eb564 tweak: better agent create error handling (#2058) 2025-08-19 00:14:50 -05:00
DaxandGitHub 50fb337270 Update duplicate-issues.yml 2025-08-18 20:00:59 -04:00
Aiden ClineandGitHub e08ec077b0 fix: ensure name isn't added as field in options: {...} (#2053) 2025-08-18 18:15:20 -05:00
Aiden ClineandGitHub 796245d146 blacklist gpt-5-chat-latest (#2048) 2025-08-18 17:50:38 -04:00
opencode 303a1044a8 release: v0.5.7 2025-08-18 21:43:17 +00:00
DaxandGitHub f19586cebd fix anthropic console auth (#2049) 2025-08-18 17:12:21 -04:00
Jay V 5d12cadba7 docs:edit 2025-08-18 13:52:53 -04:00
Jay V 745988f9e3 docs:edit 2025-08-18 13:51:08 -04:00
Jay V 61580e6dce docs: edits 2025-08-18 13:31:01 -04:00
Jay V 2dea8f0f6b docs: add tui doc 2025-08-18 13:31:01 -04:00
opencode 446ce488c0 release: v0.5.6 2025-08-18 15:56:22 +00:00
John ConnorandGitHub 21b000aed0 Remove redundant line from agents.mdx (#2031) 2025-08-18 08:34:57 -05:00
GitHub Action 0cdd8be70a ignore: update download stats 2025-08-18 2025-08-18 12:04:33 +00:00
adamdotdevin 2f4db2777c fix(tui): title bg color missing on system theme 2025-08-18 06:00:38 -05:00
YtzhakandGitHub 667ff90dd6 feat: add shimmer text rendering (#2027) 2025-08-18 05:55:01 -05:00
spoons-and-mirrorsandGitHub cd3d91209a tweak(timeline): add a dot to the session timeline modal for better visual cue of session's revert point (#1978) 2025-08-18 05:50:43 -05:00
Frank 75ed131abf sync 2025-08-18 07:58:46 +00:00
Frank 2034fabc7d Squashed commit of the following:
publish-github-action / publish (push) Has been cancelled
commit 7b2ad6a1abf88e0731f15bbf6e281b29a610dd76
Merge: 74c85391 847a63e1
Author: Frank <frank@sst.dev>
Date:   Mon Aug 18 15:31:54 2025 +0800

    Merge branch 'dev' into github

commit 74c85391b576d01df298f6c30e3399b281b5c997
Author: Frank <frank@sst.dev>
Date:   Mon Aug 18 15:30:14 2025 +0800

    sync

commit 0d27f8e490f1aa242e1a3fcd1f21eb077f852207
Author: Frank <frank@sst.dev>
Date:   Mon Aug 18 14:30:57 2025 +0800

    sync

commit 0cf7e6c89f173b053f37cc0d316011b3e9d5fcc4
Author: Frank <frank@sst.dev>
Date:   Mon Aug 18 11:54:57 2025 +0800

    sync

commit a782cb7a268bf98916c3850083eaf44ebc38de05
Author: Frank <frank@sst.dev>
Date:   Mon Aug 18 11:53:25 2025 +0800

    sync

commit aa557014584abaf462656ba9b1de7c8bd6e9b9d8
Author: Frank <frank@sst.dev>
Date:   Mon Aug 18 11:48:10 2025 +0800

    sync

commit 73c8150479bd3c965087c634102df047a36b40ab
Author: Frank <frank@sst.dev>
Date:   Mon Aug 18 01:29:29 2025 +0800

    sync

commit c5325134e80ce3f9e2cb88e5a51893e4ffd880c2
Author: Frank <frank@sst.dev>
Date:   Mon Aug 18 01:07:48 2025 +0800

    sync

commit c5b646aa88760731ac9cd221f677bd400c31224b
Author: Frank <frank@sst.dev>
Date:   Mon Aug 18 01:02:02 2025 +0800

    sync

commit 27f7cc86ab4713a26d316ae71d2aa5978aaa2007
Author: Frank <frank@sst.dev>
Date:   Mon Aug 18 00:59:22 2025 +0800

    sync

commit 0a6152a0e0c2bb0e5b7cafbcb92b908433dd6c5b
Author: Frank <frank@sst.dev>
Date:   Sun Aug 17 18:11:31 2025 +0800

    fix /opencode trigger

commit f1089103c607ac11251cac5e032e62c8b4667b30
Author: Frank <frank@sst.dev>
Date:   Sun Aug 17 17:55:14 2025 +0800

    sync

commit 3ad18240248301380a68880315bfa83c18e9652d
Author: Frank <frank@sst.dev>
Date:   Sun Aug 17 17:44:11 2025 +0800

    sync

commit 24f0f81773762a38ba0a26e599b718495e2f4b54
Author: Frank <frank@sst.dev>
Date:   Sun Aug 17 17:18:22 2025 +0800

    sync

commit bc199d32bed9679d2f80ade527fa57a91e0883ca
Author: Frank <frank@sst.dev>
Date:   Sun Aug 17 16:59:03 2025 +0800

    sync

commit 6cf860be843e94401166a6de83e36d6bdd8ca6d7
Author: Frank <frank@sst.dev>
Date:   Sun Aug 17 16:54:48 2025 +0800

    sync

commit f5f753ff38498062b2e3de38a1be94158fce1463
Author: Frank <frank@sst.dev>
Date:   Sun Aug 17 14:43:12 2025 +0800

    sync

commit 26d2e23a3ee99141a5951a153e444a1be25548dc
Author: Frank <frank@sst.dev>
Date:   Sun Aug 17 14:33:40 2025 +0800

    sync

commit c5b3f54a0ae6064ff51c11ade41e21b594939715
Author: Frank <frank@sst.dev>
Date:   Sun Aug 17 14:16:10 2025 +0800

    sync

commit 1c74e9a7ad35551eea53d0e51dcd28e6ae30a944
Author: Frank <frank@sst.dev>
Date:   Sun Aug 17 08:17:53 2025 +0800

    sync

commit 89052dc9aaf7e4f02b7ca869ef6017322ee21c94
Author: Frank <frank@sst.dev>
Date:   Sun Aug 17 08:12:43 2025 +0800

    sync

commit 42931d4d2a942eedef44f5570a57bf84df26ecfa
Author: Frank <frank@sst.dev>
Date:   Sun Aug 17 08:08:37 2025 +0800

    sync

commit f22e97dd051ae3f592f4258a8d0270ca7fd60338
Author: Frank <frank@sst.dev>
Date:   Sun Aug 17 08:01:57 2025 +0800

    sync

commit 2dda422ef85d2308b459cebe7f202b7fb782e75e
Author: Frank <frank@sst.dev>
Date:   Sun Aug 17 07:55:38 2025 +0800

    sync

commit b8be1d0e9e89732bd60185c724cda72b8de5f145
Author: Frank <frank@sst.dev>
Date:   Sun Aug 17 07:48:18 2025 +0800

    sync

commit 78c84b96a3c8aa78e0ffa089a2a72ad80348fe72
Author: Frank <frank@sst.dev>
Date:   Sat Aug 16 20:49:26 2025 +0800

    sync

commit dd9c0c83090ea6c5da963303227a1e09a8434994
Author: Frank <frank@sst.dev>
Date:   Sat Aug 16 20:47:25 2025 +0800

    sync

commit 5eb917abba182712d1581376e95de45a092bbb24
Author: Frank <frank@sst.dev>
Date:   Sat Aug 16 20:35:48 2025 +0800

    sync

commit 43cf83e7ccbc99484602b06cbb6aafdbc63bf11c
Author: Frank <frank@sst.dev>
Date:   Sat Aug 16 20:32:49 2025 +0800

    sync

commit 10673ca3d2e1572e15c944ddd7d7af8175971f74
Author: Frank <frank@sst.dev>
Date:   Sat Aug 16 19:55:53 2025 +0800

    sync

commit c45ae8a233ed64c49a08b98f3ad01e0348b2df22
Author: Frank <frank@sst.dev>
Date:   Sat Aug 16 19:53:52 2025 +0800

    sync

commit 3c329dee05ecda95f5d249552aafc885997f07f2
Author: Frank <frank@sst.dev>
Date:   Sat Aug 16 19:49:56 2025 +0800

    sync

commit 5797048db864142f15d73c854131a77a31a421ee
Author: Frank <frank@sst.dev>
Date:   Sat Aug 16 18:00:04 2025 +0800

    sync

commit 2741338e8a27e57d9d023cf9c0a6a05276b82f41
Author: Frank <frank@sst.dev>
Date:   Sat Aug 16 17:54:42 2025 +0800

    sync

commit a51a8ca6d094bd5f98330c730d335285688c6ed8
Author: Frank <frank@sst.dev>
Date:   Fri Aug 15 18:59:29 2025 +0800

    sync

commit f4eeeb612dfa6f1714a954dd167519ade0c36a2d
Author: Frank <frank@sst.dev>
Date:   Fri Aug 15 18:56:35 2025 +0800

    sync

commit 1d0509c5630904a5a9e89ce0de09fbebb6f711be
Author: Frank <frank@sst.dev>
Date:   Fri Aug 15 18:54:21 2025 +0800

    sync

commit 339807d1b88d2439e9543b5da4ca2538a49f4ab8
Author: Frank <frank@sst.dev>
Date:   Fri Aug 15 18:49:22 2025 +0800

    sync

commit 70b4b78922fe80424d8922bb999ed84d28dff005
Author: Frank <frank@sst.dev>
Date:   Fri Aug 15 18:04:57 2025 +0800

    sync
2025-08-18 15:34:28 +08:00
Aiden ClineandGitHub 847a63e15a fix: gh install trim remote origin (#2030) 2025-08-17 22:45:22 -05:00
Aiden ClineandGitHub ebd1b18b70 fix: better binary file detection (#2025) 2025-08-17 17:59:51 -05:00
GitHub Action de1764841c ignore: update download stats 2025-08-17 2025-08-17 12:03:50 +00:00
5d5ac168a4 fix(opencode): add ulid dependency (#1988) (#1989)
Co-authored-by: Dax <mail@thdxr.com>
2025-08-16 23:21:29 -04:00
LucasandGitHub 5d8d896fa2 feat(lsp): add rust-analyzer (#1972) 2025-08-16 22:59:51 -04:00
Aiden ClineandGitHub 85c6301ac5 fix: lsp bug (#1994) 2025-08-16 22:59:18 -04:00
Aiden ClineandGitHub 664d826642 fix: install script didnt use -e flag (#2009) 2025-08-16 22:55:49 -04:00
spoons-and-mirrorsandGitHub 1e204c23b9 tweak(config): make markdown agent files in subfolder discoverable .opencode/agent/some-folder/*.md (#1999) 2025-08-16 22:55:14 -04:00
Aiden ClineandGitHub daea79c0d4 feat: top level tool config (#2008) 2025-08-16 22:51:56 -04:00
Aiden ClineandGitHub 9c7fa35051 docs: more troubleshooting examples (#2004) 2025-08-16 19:33:49 -05:00
opencode 0b45187dc7 release: v0.5.5 2025-08-16 15:00:04 +00:00
Yihui KhuuandGitHub 3f3da44ed9 fix(tui): text selection is sometimes not cleared when click+release without dragging (#1993) 2025-08-16 09:16:09 -05:00
Yihui KhuuandGitHub b3885d1614 feat(tui): retain cache when cycling between subagent/parent sessions for perf (#1981) 2025-08-16 08:58:13 -05:00
Aiden ClineandGitHub ca3769b7fa tweak: plan prompt, more explicit about not modifying files (#1991) 2025-08-16 08:56:43 -05:00
GitHub Action 99e740e692 ignore: update download stats 2025-08-16 2025-08-16 12:03:50 +00:00
Aiden ClineandGitHub 576f5242bc fix: remove unsupported mode flag, change to agent (#1979) 2025-08-15 22:02:06 -05:00
Dax Raad f40feed190 wip: cloud 2025-08-15 19:50:46 -04:00
Dax Raad 6bbc4cca92 wip: fix CSS syntax issues in index.css 2025-08-15 19:42:50 -04:00
Dax Raad 10dfc7893a wip: replace hardcoded spacing and font values with design tokens 2025-08-15 19:39:34 -04:00
Dax Raad 4c783a362a wip: agent css 2025-08-15 19:34:45 -04:00
Dax Raad 3f9203f9fa wip: css agent 2025-08-15 19:32:32 -04:00
Dax Raad 07cf8847fb wip: cloud stuff 2025-08-15 19:29:42 -04:00
opencode 650e67f1df release: v0.5.4 2025-08-15 22:52:49 +00:00
Aiden ClineandGitHub e545bfef1f tweak: fix scroll speed (#1974) 2025-08-15 16:19:58 -05:00
Timo ClasenandGitHub af5f7d0887 fix: run command (#1971) 2025-08-15 15:58:20 -05:00
opencode 314f7c56e7 release: v0.5.3 2025-08-15 18:56:32 +00:00
adamdotdevin 58ca434c78 fix: some visual bugs in dialogs 2025-08-15 13:50:54 -05:00
Yihui KhuuandGitHub 70f14cccd6 feat(shell): load .zshenv and respect ZDOTDIR if present (#1958) 2025-08-15 13:24:34 -05:00
Yihui KhuuandGitHub 86df4073d1 fix(shell): commands expecting stdin will be "working" indefinitely (#1964) 2025-08-15 13:23:59 -05:00
spoons-and-mirrorsandGitHub 69117fa453 feat(TUI): improves UX with message navigation modal to jump and restore to specific messages (#1969) 2025-08-15 13:23:21 -05:00
Aiden ClineandGitHub dc01071498 feat: add scroll speed to config (#1968) 2025-08-15 13:21:02 -05:00
318 changed files with 14817 additions and 10824 deletions
+2 -4
View File
@@ -32,14 +32,12 @@ jobs:
"webfetch": "deny"
}
run: |
opencode run -m anthropic/claude-sonnet-4-20250514 "A new issue has been created: '${{ github.event.issue.title }}'
opencode run -m anthropic/claude-sonnet-4-20250514 "A new issue has been created:'
Issue number:
${{ github.event.issue.number }}
Issue body:
${{ github.event.issue.body }}
Please search through existing issues (excluding #${{ github.event.issue.number }}) in this repository to find any potential duplicates of this new issue.
Lookup this issue and search through existing issues (excluding #${{ github.event.issue.number }}) in this repository to find any potential duplicates of this new issue.
Consider:
1. Similar titles or descriptions
2. Same error messages or symptoms
+6 -8
View File
@@ -8,22 +8,20 @@ jobs:
opencode:
if: |
contains(github.event.comment.body, ' /oc') ||
startsWith(github.event.comment.body, '/oc') ||
contains(github.event.comment.body, ' /opencode') ||
startsWith(github.event.comment.body, '/opencode')
contains(github.event.comment.body, ' /opencode')
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
contents: read
pull-requests: read
issues: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run opencode
uses: sst/opencode/github@latest
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
with:
model: anthropic/claude-sonnet-4-20250514
model: opencode/sonic
+1
View File
@@ -1,5 +1,6 @@
.DS_Store
node_modules
.worktrees
.sst
.env
.idea
+22 -5
View File
@@ -1,6 +1,4 @@
---
model: openai/gpt-5
reasoningEffort: medium
description: ALWAYS use this when writing docs
---
@@ -8,7 +6,26 @@ You are an expert technical documentation writer
You are not verbose
Every chunk of text should be followed by an example or something besides text
to look at.
The title of the page should be a word or a 2-3 word phrase
Chunks of text should not be more than 2 sentences long.
The description should be one short line, should not start with "The", should
avoid repeating the title of the page, should be 5-10 words long
Chunks of text should not be more than 2 sentences long
Each section is spearated by a divider of 3 dashes
The section titles are short with only the first letter of the word capitalized
The section titles are in the imperative mood
The section titles should not repeat the term used in the page title, for
example, if the page title is "Models", avoid using a section title like "Add
new models". This might be unavoidable in some cases, but try to avoid it.
Check out the /packages/web/src/content/docs/docs/index.mdx as an example.
For JS or TS code snippets remove trailing semicolons and any trailing commas
that might not be needed.
If you are making a commit prefix the commit message with `docs:`
+9
View File
@@ -0,0 +1,9 @@
commit and push
make sure it includes a prefix like
docs:
tui:
core:
ci:
ignore:
wip:
+8
View File
@@ -0,0 +1,8 @@
---
description: hello world
---
hey there $ARGUMENTS
!`ls`
check out @README.md
+4
View File
@@ -10,3 +10,7 @@
- AVOID `let` statements
- PREFER single word variable names where possible
- Use as many bun apis as possible like Bun.file()
## Debugging
- To test opencode in the `packages/opencode` directory you can run `bun dev`
+1 -1
View File
@@ -107,4 +107,4 @@ The other confusingly named repo has no relation to this one. You can [read the
---
**Join our community** [Discord](https://discord.gg/opencode) | [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/anomaly_inv)
+17
View File
@@ -49,3 +49,20 @@
| 2025-08-13 | 182,997 (+6,690) | 177,182 (+5,306) | 360,179 (+11,996) |
| 2025-08-14 | 189,063 (+6,066) | 179,741 (+2,559) | 368,804 (+8,625) |
| 2025-08-15 | 193,608 (+4,545) | 181,792 (+2,051) | 375,400 (+6,596) |
| 2025-08-16 | 198,118 (+4,510) | 184,558 (+2,766) | 382,676 (+7,276) |
| 2025-08-17 | 201,299 (+3,181) | 186,269 (+1,711) | 387,568 (+4,892) |
| 2025-08-18 | 204,559 (+3,260) | 187,399 (+1,130) | 391,958 (+4,390) |
| 2025-08-19 | 209,814 (+5,255) | 189,668 (+2,269) | 399,482 (+7,524) |
| 2025-08-20 | 214,497 (+4,683) | 191,481 (+1,813) | 405,978 (+6,496) |
| 2025-08-21 | 220,465 (+5,968) | 194,784 (+3,303) | 415,249 (+9,271) |
| 2025-08-22 | 225,899 (+5,434) | 197,204 (+2,420) | 423,103 (+7,854) |
| 2025-08-23 | 229,005 (+3,106) | 199,238 (+2,034) | 428,243 (+5,140) |
| 2025-08-24 | 232,098 (+3,093) | 201,157 (+1,919) | 433,255 (+5,012) |
| 2025-08-25 | 236,607 (+4,509) | 202,650 (+1,493) | 439,257 (+6,002) |
| 2025-08-26 | 242,783 (+6,176) | 205,242 (+2,592) | 448,025 (+8,768) |
| 2025-08-27 | 248,409 (+5,626) | 205,242 (+0) | 453,651 (+5,626) |
| 2025-08-28 | 252,796 (+4,387) | 205,242 (+0) | 458,038 (+4,387) |
| 2025-08-29 | 256,045 (+3,249) | 211,075 (+5,833) | 467,120 (+9,082) |
| 2025-08-30 | 258,863 (+2,818) | 212,397 (+1,322) | 471,260 (+4,140) |
| 2025-08-31 | 262,004 (+3,141) | 213,944 (+1,547) | 475,948 (+4,688) |
| 2025-09-01 | 265,359 (+3,355) | 215,115 (+1,171) | 480,474 (+4,526) |
+1081 -639
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
dist
.wrangler
.output
.vercel
.netlify
.vinxi
app.config.timestamp_*.js
# Environment
.env
.env*.local
# dependencies
/node_modules
# IDEs and editors
/.idea
.project
.classpath
*.launch
.settings/
# Temp
gitignore
# System Files
.DS_Store
Thumbs.db
+149
View File
@@ -0,0 +1,149 @@
---
description: use whenever you are styling a ui with css
---
you are very good at writing clean maintainable css using modern techniques
css is structured like this
```css
[data-page="home"] {
[data-component="header"] {
[data-slot="logo"] {
}
}
}
```
top level pages are scoped using `data-page`
pages can break down into components using `data-component`
components can break down into slots using `data-slot`
structure things so that this hierarchy is followed IN YOUR CSS - you should rarely need to
nest components inside other components. you should NEVER nest components inside
slots. you should NEVER nest slots inside other slots.
**IMPORTANT: This hierarchy rule applies to CSS structure, NOT JSX/DOM structure.**
The hierarchy in css file does NOT have to match the hierarchy in the dom - you
can put components or slots at the same level in CSS even if one goes inside another in the DOM.
Your JSX can nest however makes semantic sense - components can be inside slots,
slots can contain components, etc. The DOM structure should be whatever makes the most
semantic and functional sense.
It is more important to follow the pages -> components -> slots structure IN YOUR CSS,
while keeping your JSX/DOM structure logical and semantic.
use data attributes to represent different states of the component
```css
[data-component="modal"] {
opacity: 0;
&[data-state="open"] {
opacity: 1;
}
}
```
this will allow jsx to control the syling
avoid selectors that just target an element type like `> span` you should assign
it a slot name. it's ok to do this sometimes where it makes sense semantically
like targeting `li` elements in a list
in terms of file structure `./src/style/` contains all universal styling rules.
these should not contain anything specific to a page
`./src/style/token` contains all the tokens used in the project
`./src/style/component` is for reusable components like buttons or inputs
page specific styles should go next to the page they are styling so
`./src/routes/about.tsx` should have its styles in `./src/routes/about.css`
`about.css` should be scoped using `data-page="about"`
## Example of correct implementation
JSX can nest however makes sense semantically:
```jsx
<div data-slot="left">
<div data-component="title">Section Title</div>
<div data-slot="content">Content here</div>
</div>
```
CSS maintains clean hierarchy regardless of DOM nesting:
```css
[data-page="home"] {
[data-component="screenshots"] {
[data-slot="left"] {
/* styles */
}
[data-slot="content"] {
/* styles */
}
}
[data-component="title"] {
/* can be at same level even though nested in DOM */
}
}
```
## Reusable Components
If a component is reused across multiple sections of the same page, define it at the page level:
```jsx
<!-- Used in multiple places on the same page -->
<section data-component="install">
<div data-component="method">
<h3 data-component="title">npm</h3>
</div>
<div data-component="method">
<h3 data-component="title">bun</h3>
</div>
</section>
<section data-component="screenshots">
<div data-slot="left">
<div data-component="title">Screenshot Title</div>
</div>
</section>
```
```css
[data-page="home"] {
/* Reusable title component defined at page level since it's used in multiple components */
[data-component="title"] {
text-transform: uppercase;
font-weight: 400;
}
[data-component="install"] {
/* install-specific styles */
}
[data-component="screenshots"] {
/* screenshots-specific styles */
}
}
```
This is correct because the `title` component has consistent styling and behavior across the page.
## Key Clarifications
1. **JSX Nesting is Flexible**: Components can be nested inside slots, slots can contain components - whatever makes semantic sense
2. **CSS Hierarchy is Strict**: Follow pages → components → slots structure in CSS
3. **Reusable Components**: Define at the appropriate level where they're shared (page level if used across the page, component level if only used within that component)
4. **DOM vs CSS Structure**: These don't need to match - optimize each for its purpose
See ./src/routes/index.css and ./src/routes/index.tsx for a complete example.
+32
View File
@@ -0,0 +1,32 @@
# SolidStart
Everything you need to build a Solid project, powered by [`solid-start`](https://start.solidjs.com);
## Creating a project
```bash
# create a new project in the current directory
npm init solid@latest
# create a new project in my-app
npm init solid@latest my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
Solid apps are built with _presets_, which optimise your project for deployment to different environments.
By default, `npm run build` will generate a Node app that you can run with `npm start`. To use a different preset, add it to the `devDependencies` in `package.json` and specify in your `app.config.js`.
## This project was created with the [Solid CLI](https://github.com/solidjs-community/solid-cli)
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig } from "@solidjs/start/config"
export default defineConfig({
vite: {
server: {
allowedHosts: true,
},
build: {
rollupOptions: {
external: ["cloudflare:workers"],
},
minify: false,
},
},
server: {
compatibilityDate: "2024-09-19",
preset: "cloudflare_module",
cloudflare: {
nodeCompat: true,
},
},
})
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@opencode/cloud-app",
"type": "module",
"scripts": {
"dev": "vinxi dev --host 0.0.0.0",
"dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai bun sst shell --stage=dev bun dev",
"build": "vinxi build",
"start": "vinxi start",
"version": "0.6.1"
},
"dependencies": {
"@ibm/plex": "6.4.1",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@solidjs/meta": "^0.29.4",
"@solidjs/router": "^0.15.0",
"@solidjs/start": "^1.1.0",
"solid-js": "catalog:",
"vinxi": "^0.5.7",
"@opencode/cloud-core": "workspace:*"
},
"engines": {
"node": ">=22"
}
}
+5
View File
@@ -0,0 +1,5 @@
<svg width="600" height="600" viewBox="0 0 600 600" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="600" height="600" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M115 180H300V420H115V180ZM253.75 229.044H161.25V370.405H253.75V229.044Z" fill="white"/>
<path d="M346.25 180H485V229.044H392.5V370.405H485V419.449H346.25V180Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 377 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+1
View File
@@ -0,0 +1 @@
@import "./style/index.css";
+22
View File
@@ -0,0 +1,22 @@
import { MetaProvider, Title, Meta } from "@solidjs/meta";
import { Router } from "@solidjs/router";
import { FileRoutes } from "@solidjs/start/router";
import { ErrorBoundary, Suspense } from "solid-js";
import "@ibm/plex/css/ibm-plex.css";
import "./app.css";
export default function App() {
return (
<Router
root={props => (
<MetaProvider>
<Title>opencode</Title>
<Meta name="description" content="opencode - The AI coding agent built for the terminal." />
<Suspense>{props.children}</Suspense>
</MetaProvider>
)}
>
<FileRoutes />
</Router>
);
}
+2
View File
@@ -0,0 +1,2 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z"/></svg>

After

Width:  |  Height:  |  Size: 212 B

+2
View File
@@ -0,0 +1,2 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 512 512"><rect width="336" height="336" x="128" y="128" fill="none" stroke="currentColor" stroke-linejoin="round" stroke-width="32" rx="57" ry="57"/><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="32" d="m383.5 128l.5-24a56.16 56.16 0 0 0-56-56H112a64.19 64.19 0 0 0-64 64v216a56.16 56.16 0 0 0 56 56h24"/></svg>

After

Width:  |  Height:  |  Size: 443 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 902 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 456 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 998 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 592 KiB

+19
View File
@@ -0,0 +1,19 @@
<svg width="289" height="50" viewBox="0 0 289 50" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.5 16.5H24.5V33H8.5V16.5Z" fill="white" fill-opacity="0.2"/>
<path d="M48.5 16.5H64.5V33H48.5V16.5Z" fill="white" fill-opacity="0.2"/>
<path d="M120.5 16.5H136.5V33H120.5V16.5Z" fill="white" fill-opacity="0.2"/>
<path d="M160.5 16.5H176.5V33H160.5V16.5Z" fill="white" fill-opacity="0.2"/>
<path d="M192.5 16.5H208.5V33H192.5V16.5Z" fill="white" fill-opacity="0.2"/>
<path d="M232.5 16.5H248.5V33H232.5V16.5Z" fill="white" fill-opacity="0.2"/>
<path d="M264.5 0H288.5V8.5H272.5V16.5H288.5V25H272.5V33H288.5V41.5H264.5V0Z" fill="white" fill-opacity="0.95"/>
<path d="M248.5 0H224.5V41.5H248.5V33H232.5V8.5H248.5V0Z" fill="white" fill-opacity="0.95"/>
<path d="M256.5 8.5H248.5V33H256.5V8.5Z" fill="white" fill-opacity="0.95"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M184.5 0H216.5V41.5H184.5V0ZM208.5 8.5H192.5V33H208.5V8.5Z" fill="white" fill-opacity="0.95"/>
<path d="M144.5 8.5H136.5V41.5H144.5V8.5Z" fill="white" fill-opacity="0.5"/>
<path d="M136.5 0H112.5V41.5H120.5V8.5H136.5V0Z" fill="white" fill-opacity="0.5"/>
<path d="M80.5 0H104.5V8.5H88.5V16.5H104.5V25H88.5V33H104.5V41.5H80.5V0Z" fill="white" fill-opacity="0.5"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M40.5 0H72.5V41.5H48.5V49.5H40.5V0ZM64.5 8.5H48.5V33H64.5V8.5Z" fill="white" fill-opacity="0.5"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.5 0H32.5V41.5955H0.5V0ZM24.5 8.5H8.5V33H24.5V8.5Z" fill="white" fill-opacity="0.5"/>
<path d="M152.5 0H176.5V8.5H160.5V33H176.5V41.5H152.5V0Z" fill="white" fill-opacity="0.95"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+18
View File
@@ -0,0 +1,18 @@
<svg width="288" height="50" viewBox="0 0 288 50" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 16.5H24V33H8V16.5Z" fill="black" fill-opacity="0.15"/>
<path d="M48 16.5H64V33H48V16.5Z" fill="black" fill-opacity="0.15"/>
<path d="M120 16.5H136V33H120V16.5Z" fill="black" fill-opacity="0.15"/>
<path d="M160 16.5H176V33H160V16.5Z" fill="black" fill-opacity="0.15"/>
<path d="M192 16.5H208V33H192V16.5Z" fill="black" fill-opacity="0.15"/>
<path d="M232 16.5H248V33H232V16.5Z" fill="black" fill-opacity="0.15"/>
<path d="M264 0H288V8.5H272V16.5H288V25H272V33H288V41.5H264V0Z" fill="black" fill-opacity="0.95"/>
<path d="M248 0H224V41.5H248V33H232V8.5H248V0Z" fill="black" fill-opacity="0.95"/>
<path d="M256 8.5H248V33H256V8.5Z" fill="black" fill-opacity="0.95"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M184 0H216V41.5H184V0ZM208 8.5H192V33H208V8.5Z" fill="black" fill-opacity="0.95"/>
<path d="M144 8.5H136V41.5H144V8.5Z" fill="black" fill-opacity="0.55"/>
<path d="M136 0H112V41.5H120V8.5H136V0Z" fill="black" fill-opacity="0.55"/>
<path d="M80 0H104V8.5H88V16.5H104V25H88V33H104V41.5H80V0Z" fill="black" fill-opacity="0.55"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M40 0H72V41.5H48V49.5H40V0ZM64 8.5H48V33H64V8.5Z" fill="black" fill-opacity="0.55"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 0H32V41.5955H0V0ZM24 8.5H8V33H24V8.5Z" fill="black" fill-opacity="0.55"/>
<path d="M152 0H176V8.5H160V33H176V41.5H152V0Z" fill="black" fill-opacity="0.95"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+12
View File
@@ -0,0 +1,12 @@
<svg width="289" height="50" viewBox="0 0 289 50" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M264.5 0H288.5V8.5H272.5V16.5H288.5V25H272.5V33H288.5V41.5H264.5V0Z" fill="black"/>
<path d="M248.5 0H224.5V41.5H248.5V33H232.5V8.5H248.5V0Z" fill="black"/>
<path d="M256.5 8.5H248.5V33H256.5V8.5Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M184.5 0H216.5V41.5H184.5V0ZM208.5 8.5H192.5V33H208.5V8.5Z" fill="black"/>
<path d="M144.5 8.5H136.5V41.5H144.5V8.5Z" fill="black"/>
<path d="M136.5 0H112.5V41.5H120.5V8.5H136.5V0Z" fill="black"/>
<path d="M80.5 0H104.5V8.5H88.5V16.5H104.5V25H88.5V33H104.5V41.5H80.5V0Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M40.5 0H72.5V41.5H48.5V49.5H40.5V0ZM64.5 8.5H48.5V33H64.5V8.5Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.5 0H32.5V41.5955H0.5V0ZM24.5 8.5H8.5V33H24.5V8.5Z" fill="black"/>
<path d="M152.5 0H176.5V8.5H160.5V33H176.5V41.5H152.5V0Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 981 B

+39
View File
@@ -0,0 +1,39 @@
import { JSX } from "solid-js"
export function IconLogo(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
<svg {...props} viewBox="0 0 289 50" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M264.5 0H288.5V8.5H272.5V16.5H288.5V25H272.5V33H288.5V41.5H264.5V0Z" fill="currentColor" />
<path d="M248.5 0H224.5V41.5H248.5V33H232.5V8.5H248.5V0Z" fill="currentColor" />
<path d="M256.5 8.5H248.5V33H256.5V8.5Z" fill="currentColor" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M184.5 0H216.5V41.5H184.5V0ZM208.5 8.5H192.5V33H208.5V8.5Z" fill="currentColor" />
<path d="M144.5 8.5H136.5V41.5H144.5V8.5Z" fill="currentColor" />
<path d="M136.5 0H112.5V41.5H120.5V8.5H136.5V0Z" fill="currentColor" />
<path d="M80.5 0H104.5V8.5H88.5V16.5H104.5V25H88.5V33H104.5V41.5H80.5V0Z" fill="currentColor" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M40.5 0H72.5V41.5H48.5V49.5H40.5V0ZM64.5 8.5H48.5V33H64.5V8.5Z" fill="currentColor" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.5 0H32.5V41.5955H0.5V0ZM24.5 8.5H8.5V33H24.5V8.5Z" fill="currentColor" />
<path d="M152.5 0H176.5V8.5H160.5V33H176.5V41.5H152.5V0Z" fill="currentColor" />
</svg>
);
}
export function IconCopy(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
<svg
{...props}
viewBox="0 0 512 512" >
<rect width="336" height="336" x="128" y="128" fill="none" stroke="currentColor" stroke-linejoin="round" stroke-width="32" rx="57" ry="57"></rect>
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="32" d="m383.5 128l.5-24a56.16 56.16 0 0 0-56-56H112a64.19 64.19 0 0 0-64 64v216a56.16 56.16 0 0 0 56 56h24"></path>
</svg>
)
}
export function IconCheck(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
<svg
{...props}
viewBox="0 0 24 24" >
<path fill="currentColor" d="M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z"></path>
</svg>
)
}
+23
View File
@@ -0,0 +1,23 @@
import { useSession } from "vinxi/http"
export interface AuthSession {
account: Record<
string,
{
id: string
email: string
}
>
current?: string
}
export function useAuthSession() {
return useSession<AuthSession>({
password: "0".repeat(32),
name: "auth",
cookie: {
secure: false,
httpOnly: true,
},
})
}
+79
View File
@@ -0,0 +1,79 @@
import { getRequestEvent } from "solid-js/web"
import { and, Database, eq, inArray } from "@opencode/cloud-core/drizzle/index.js"
import { WorkspaceTable } from "@opencode/cloud-core/schema/workspace.sql.js"
import { UserTable } from "@opencode/cloud-core/schema/user.sql.js"
import { query, redirect } from "@solidjs/router"
import { AccountTable } from "@opencode/cloud-core/schema/account.sql.js"
import { Actor } from "@opencode/cloud-core/actor.js"
import { createClient } from "@openauthjs/openauth/client"
import { useAuthSession } from "./auth.session"
export const AuthClient = createClient({
clientID: "app",
issuer: import.meta.env.VITE_AUTH_URL,
})
export const getActor = query(async (): Promise<Actor.Info> => {
"use server"
const evt = getRequestEvent()
if (!evt) throw new Error("No request event")
const url = new URL(evt.request.headers.has("x-server-id") ? evt.request.headers.get("referer")! : evt.request.url)
const auth = await useAuthSession()
const splits = url.pathname.split("/").filter(Boolean)
if (splits[0] !== "workspace") {
if (auth.data.current) {
const current = auth.data.account[auth.data.current]
return {
type: "account",
properties: {
email: current.email,
accountID: current.id,
},
}
}
if (Object.keys(auth.data.account ?? {}).length > 0) {
const current = Object.values(auth.data.account)[0]
await auth.update((val) => ({
...val,
current: current.id,
}))
return {
type: "account",
properties: {
email: current.email,
accountID: current.id,
},
}
}
return {
type: "public",
properties: {},
}
}
const workspaceHint = splits[1]
const accounts = Object.keys(auth.data.account ?? {})
const result = await Database.transaction(async (tx) => {
return await tx
.select({
user: UserTable,
})
.from(AccountTable)
.innerJoin(UserTable, and(eq(UserTable.email, AccountTable.email)))
.innerJoin(WorkspaceTable, eq(WorkspaceTable.id, UserTable.workspaceID))
.where(and(inArray(AccountTable.id, accounts), eq(WorkspaceTable.id, workspaceHint)))
.limit(1)
.execute()
.then((x) => x[0])
})
if (result) {
return {
type: "user",
properties: {
userID: result.user.id,
workspaceID: result.user.workspaceID,
},
}
}
throw redirect("/auth/authorize")
}, "actor")
+16
View File
@@ -0,0 +1,16 @@
import { Actor } from "@opencode/cloud-core/actor.js"
import { getActor } from "./auth"
import { query } from "@solidjs/router"
export async function withActor<T>(fn: () => T) {
const actor = await getActor()
return Actor.provide(actor.type, actor.properties, fn)
}
export function actorQuery<T>(cb: () => T, name: string) {
"use server"
return query(async () => {
const actor = await getActor()
return withActor(cb)
}, name)
}
+4
View File
@@ -0,0 +1,4 @@
// @refresh reload
import { mount, StartClient } from "@solidjs/start/client";
mount(() => <StartClient />, document.getElementById("app")!);
+25
View File
@@ -0,0 +1,25 @@
// @refresh reload
import { createHandler, StartServer } from "@solidjs/start/server"
export default createHandler(() => (
<StartServer
document={({ assets, children, scripts }) => (
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.svg" />
<meta property="og:image" content="/social-share.png" />
<meta property="twitter:image" content="/social-share.png" />
{assets}
</head>
<body>
<div id="app">{children}</div>
{scripts}
</body>
</html>
)}
/>
), {
mode: "sync",
})
+1
View File
@@ -0,0 +1 @@
/// <reference types="@solidjs/start/env" />
+19
View File
@@ -0,0 +1,19 @@
import { Title } from "@solidjs/meta";
import { HttpStatusCode } from "@solidjs/start";
export default function NotFound() {
return (
<main>
<Title>Not Found</Title>
<HttpStatusCode code={404} />
<h1>Page Not Found</h1>
<p>
Visit{" "}
<a href="https://start.solidjs.com" target="_blank">
start.solidjs.com
</a>{" "}
to learn how to build SolidStart apps.
</p>
</main>
);
}
+7
View File
@@ -0,0 +1,7 @@
import type { APIEvent } from "@solidjs/start/server"
import { AuthClient } from "~/context/auth"
export async function GET(input: APIEvent) {
const result = await AuthClient.authorize(new URL("./callback", input.request.url).toString(), "code")
return Response.redirect(result.url, 302)
}
+31
View File
@@ -0,0 +1,31 @@
import { redirect } from "@solidjs/router"
import type { APIEvent } from "@solidjs/start/server"
import { AuthClient } from "~/context/auth"
import { useAuthSession } from "~/context/auth.session"
export async function GET(input: APIEvent) {
const url = new URL(input.request.url)
const code = url.searchParams.get("code")
if (!code) throw new Error("No code found")
const result = await AuthClient.exchange(code, `${url.origin}${url.pathname}`)
if (result.err) {
throw new Error(result.err.message)
}
const decoded = AuthClient.decode(result.tokens.access, {} as any)
if (decoded.err) throw new Error(decoded.err.message)
const session = await useAuthSession()
const id = decoded.subject.properties.accountID
await session.update((value) => {
return {
...value,
account: {
[id]: {
id,
email: decoded.subject.properties.email,
},
},
current: id,
}
})
return redirect("/")
}
+7
View File
@@ -0,0 +1,7 @@
import type { APIEvent } from "@solidjs/start/server"
import { AuthClient } from "~/context/auth"
export async function GET(input: APIEvent) {
const result = await AuthClient.authorize(new URL("./callback", input.request.url).toString(), "code")
return Response.redirect(result.url, 302)
}
+13
View File
@@ -0,0 +1,13 @@
import type { APIEvent } from "@solidjs/start/server"
import { json } from "@solidjs/router"
import { Database } from "@opencode/cloud-core/drizzle/index.js"
import { UserTable } from "@opencode/cloud-core/schema/user.sql.js"
export async function GET(evt: APIEvent) {
return json({
data: await Database.use(async (tx) => {
const result = await tx.$count(UserTable)
return result
}),
})
}
@@ -0,0 +1,576 @@
import { Resource } from "@opencode/cloud-resource"
import { Billing } from "@opencode/cloud-core/billing.js"
import type { APIEvent } from "@solidjs/start/server"
import { Database, eq, sql } from "@opencode/cloud-core/drizzle/index.js"
import { BillingTable, PaymentTable } from "@opencode/cloud-core/schema/billing.sql.js"
import { Identifier } from "@opencode/cloud-core/identifier.js"
import { centsToMicroCents } from "@opencode/cloud-core/util/price.js"
import { Actor } from "@opencode/cloud-core/actor.js"
import { KeyTable } from "@opencode/cloud-core/schema/key.sql.js"
const SUPPORTED_MODELS = {
// "anthropic/claude-sonnet-4": {
// input: 0.0000015,
// output: 0.000006,
// reasoning: 0.0000015,
// cacheRead: 0.0000001,
// cacheWrite: 0.0000001,
// model: () =>
// createAnthropic({
// apiKey: Resource.ANTHROPIC_API_KEY.value,
// })("claude-sonnet-4-20250514"),
// },
// "openai/gpt-4.1": {
// input: 0.0000015,
// output: 0.000006,
// reasoning: 0.0000015,
// cacheRead: 0.0000001,
// cacheWrite: 0.0000001,
// model: () =>
// createOpenAI({
// apiKey: Resource.OPENAI_API_KEY.value,
// })("gpt-4.1"),
// },
// "zhipuai/glm-4.5-flash": {
// input: 0,
// output: 0,
// reasoning: 0,
// cacheRead: 0,
// cacheWrite: 0,
// model: () =>
// createOpenAICompatible({
// name: "Zhipu AI",
// baseURL: "https://api.z.ai/api/paas/v4",
// apiKey: Resource.ZHIPU_API_KEY.value,
// })("glm-4.5-flash"),
// },
}
export async function POST(input: APIEvent) {
// Check auth header
const authHeader = input.request.headers.get("authorization")
if (!authHeader || !authHeader.startsWith("Bearer "))
return Response.json(
{
error: {
message: "Missing API key.",
type: "invalid_request_error",
param: null,
code: "unauthorized",
},
},
{ status: 401 },
)
const apiKey = authHeader.split(" ")[1]
// Check against KeyTable
const keyRecord = await Database.use((tx) =>
tx
.select({
id: KeyTable.id,
workspaceID: KeyTable.workspaceID,
})
.from(KeyTable)
.where(eq(KeyTable.key, apiKey))
.then((rows) => rows[0]),
)
if (!keyRecord)
return Response.json(
{
error: {
message: "Invalid API key.",
type: "invalid_request_error",
param: null,
code: "unauthorized",
},
},
{ status: 401 },
)
/*
return await Actor.provide("system", { workspaceID: keyRecord.workspaceID }, async () => {
try {
// Check balance
const customer = await Billing.get()
if (customer.balance <= 0) {
return Response.json(
{
error: {
message: "Insufficient balance",
type: "insufficient_quota",
param: null,
code: "insufficient_quota",
},
},
{ status: 401 },
)
}
const body = await input.request.json<ChatCompletionCreateParamsBase>()
const model = SUPPORTED_MODELS[body.model as keyof typeof SUPPORTED_MODELS]?.model()
if (!model) throw new Error(`Unsupported model: ${body.model}`)
const requestBody = transformOpenAIRequestToAiSDK()
return body.stream ? await handleStream() : await handleGenerate()
async function handleStream() {
const result = await model.doStream({
...requestBody,
})
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
const id = `chatcmpl-${Date.now()}`
const created = Math.floor(Date.now() / 1000)
try {
for await (const chunk of result.stream) {
console.log("!!! CHUNK !!! : " + chunk.type)
switch (chunk.type) {
case "text-delta": {
const data = {
id,
object: "chat.completion.chunk",
created,
model: body.model,
choices: [
{
index: 0,
delta: {
content: chunk.delta,
},
finish_reason: null,
},
],
}
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`))
break
}
case "reasoning-delta": {
const data = {
id,
object: "chat.completion.chunk",
created,
model: body.model,
choices: [
{
index: 0,
delta: {
reasoning_content: chunk.delta,
},
finish_reason: null,
},
],
}
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`))
break
}
case "tool-call": {
const data = {
id,
object: "chat.completion.chunk",
created,
model: body.model,
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: 0,
id: chunk.toolCallId,
type: "function",
function: {
name: chunk.toolName,
arguments: chunk.input,
},
},
],
},
finish_reason: null,
},
],
}
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`))
break
}
case "error": {
const data = {
id,
object: "chat.completion.chunk",
created,
model: body.model,
choices: [
{
index: 0,
delta: {},
finish_reason: "stop",
},
],
error: {
message: typeof chunk.error === "string" ? chunk.error : chunk.error,
type: "server_error",
},
}
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`))
controller.enqueue(encoder.encode("data: [DONE]\n\n"))
controller.close()
break
}
case "finish": {
const data = {
id,
object: "chat.completion.chunk",
created,
model: body.model,
choices: [
{
index: 0,
delta: {},
finish_reason:
{
stop: "stop",
length: "length",
"content-filter": "content_filter",
"tool-calls": "tool_calls",
error: "stop",
other: "stop",
unknown: "stop",
}[chunk.finishReason] || "stop",
},
],
usage: {
prompt_tokens: chunk.usage.inputTokens,
completion_tokens: chunk.usage.outputTokens,
total_tokens: chunk.usage.totalTokens,
completion_tokens_details: {
reasoning_tokens: chunk.usage.reasoningTokens,
},
prompt_tokens_details: {
cached_tokens: chunk.usage.cachedInputTokens,
},
},
}
await trackUsage(body.model, chunk.usage, chunk.providerMetadata)
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`))
controller.enqueue(encoder.encode("data: [DONE]\n\n"))
controller.close()
break
}
//case "stream-start":
//case "response-metadata":
case "text-start":
case "text-end":
case "reasoning-start":
case "reasoning-end":
case "tool-input-start":
case "tool-input-delta":
case "tool-input-end":
case "raw":
default:
// Log unknown chunk types for debugging
console.warn(`Unknown chunk type: ${(chunk as any).type}`)
break
}
}
} catch (error) {
controller.error(error)
}
},
})
return new Response(stream, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
})
}
async function handleGenerate() {
const response = await model.doGenerate({
...requestBody,
})
await trackUsage(body.model, response.usage, response.providerMetadata)
return c.json({
id: `chatcmpl-${Date.now()}`,
object: "chat.completion" as const,
created: Math.floor(Date.now() / 1000),
model: body.model,
choices: [
{
index: 0,
message: {
role: "assistant" as const,
content: response.content?.find((c) => c.type === "text")?.text ?? "",
reasoning_content: response.content?.find((c) => c.type === "reasoning")?.text,
tool_calls: response.content
?.filter((c) => c.type === "tool-call")
.map((toolCall) => ({
id: toolCall.toolCallId,
type: "function" as const,
function: {
name: toolCall.toolName,
arguments: toolCall.input,
},
})),
},
finish_reason:
(
{
stop: "stop",
length: "length",
"content-filter": "content_filter",
"tool-calls": "tool_calls",
error: "stop",
other: "stop",
unknown: "stop",
} as const
)[response.finishReason] || "stop",
},
],
usage: {
prompt_tokens: response.usage?.inputTokens,
completion_tokens: response.usage?.outputTokens,
total_tokens: response.usage?.totalTokens,
completion_tokens_details: {
reasoning_tokens: response.usage?.reasoningTokens,
},
prompt_tokens_details: {
cached_tokens: response.usage?.cachedInputTokens,
},
},
})
}
function transformOpenAIRequestToAiSDK() {
const prompt = transformMessages()
const tools = transformTools()
return {
prompt,
maxOutputTokens: body.max_tokens ?? body.max_completion_tokens ?? undefined,
temperature: body.temperature ?? undefined,
topP: body.top_p ?? undefined,
frequencyPenalty: body.frequency_penalty ?? undefined,
presencePenalty: body.presence_penalty ?? undefined,
providerOptions: body.reasoning_effort
? {
anthropic: {
reasoningEffort: body.reasoning_effort,
},
}
: undefined,
stopSequences: (typeof body.stop === "string" ? [body.stop] : body.stop) ?? undefined,
responseFormat: (() => {
if (!body.response_format) return { type: "text" as const }
if (body.response_format.type === "json_schema")
return {
type: "json" as const,
schema: body.response_format.json_schema.schema,
name: body.response_format.json_schema.name,
description: body.response_format.json_schema.description,
}
if (body.response_format.type === "json_object") return { type: "json" as const }
throw new Error("Unsupported response format")
})(),
seed: body.seed ?? undefined,
tools: tools.tools,
toolChoice: tools.toolChoice,
}
function transformTools() {
const { tools, tool_choice } = body
if (!tools || tools.length === 0) {
return { tools: undefined, toolChoice: undefined }
}
const aiSdkTools = tools.map((tool) => {
return {
type: tool.type,
name: tool.function.name,
description: tool.function.description,
inputSchema: tool.function.parameters!,
}
})
let aiSdkToolChoice
if (tool_choice == null) {
aiSdkToolChoice = undefined
} else if (tool_choice === "auto") {
aiSdkToolChoice = { type: "auto" as const }
} else if (tool_choice === "none") {
aiSdkToolChoice = { type: "none" as const }
} else if (tool_choice === "required") {
aiSdkToolChoice = { type: "required" as const }
} else if (tool_choice.type === "function") {
aiSdkToolChoice = {
type: "tool" as const,
toolName: tool_choice.function.name,
}
}
return { tools: aiSdkTools, toolChoice: aiSdkToolChoice }
}
function transformMessages() {
const { messages } = body
const prompt: LanguageModelV2Prompt = []
for (const message of messages) {
switch (message.role) {
case "system": {
prompt.push({
role: "system",
content: message.content as string,
})
break
}
case "user": {
if (typeof message.content === "string") {
prompt.push({
role: "user",
content: [{ type: "text", text: message.content }],
})
} else {
const content = message.content.map((part) => {
switch (part.type) {
case "text":
return { type: "text" as const, text: part.text }
case "image_url":
return {
type: "file" as const,
mediaType: "image/jpeg" as const,
data: part.image_url.url,
}
default:
throw new Error(`Unsupported content part type: ${(part as any).type}`)
}
})
prompt.push({
role: "user",
content,
})
}
break
}
case "assistant": {
const content: Array<
| { type: "text"; text: string }
| {
type: "tool-call"
toolCallId: string
toolName: string
input: any
}
> = []
if (message.content) {
content.push({
type: "text",
text: message.content as string,
})
}
if (message.tool_calls) {
for (const toolCall of message.tool_calls) {
content.push({
type: "tool-call",
toolCallId: toolCall.id,
toolName: toolCall.function.name,
input: JSON.parse(toolCall.function.arguments),
})
}
}
prompt.push({
role: "assistant",
content,
})
break
}
case "tool": {
prompt.push({
role: "tool",
content: [
{
type: "tool-result",
toolName: "placeholder",
toolCallId: message.tool_call_id,
output: {
type: "text",
value: message.content as string,
},
},
],
})
break
}
default: {
throw new Error(`Unsupported message role: ${message.role}`)
}
}
}
return prompt
}
}
async function trackUsage(model: string, usage: LanguageModelUsage, providerMetadata?: ProviderMetadata) {
const modelData = SUPPORTED_MODELS[model as keyof typeof SUPPORTED_MODELS]
if (!modelData) throw new Error(`Unsupported model: ${model}`)
const inputTokens = usage.inputTokens ?? 0
const outputTokens = usage.outputTokens ?? 0
const reasoningTokens = usage.reasoningTokens ?? 0
const cacheReadTokens = usage.cachedInputTokens ?? 0
const cacheWriteTokens =
providerMetadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
// @ts-expect-error
providerMetadata?.["bedrock"]?.["usage"]?.["cacheWriteInputTokens"] ??
0
const inputCost = modelData.input * inputTokens
const outputCost = modelData.output * outputTokens
const reasoningCost = modelData.reasoning * reasoningTokens
const cacheReadCost = modelData.cacheRead * cacheReadTokens
const cacheWriteCost = modelData.cacheWrite * cacheWriteTokens
const costInCents = (inputCost + outputCost + reasoningCost + cacheReadCost + cacheWriteCost) * 100
await Billing.consume({
model,
inputTokens,
outputTokens,
reasoningTokens,
cacheReadTokens,
cacheWriteTokens,
costInCents,
})
await Database.use((tx) =>
tx
.update(KeyTable)
.set({ timeUsed: sql`now()` })
.where(eq(KeyTable.id, keyRecord.id)),
)
}
} catch (error: any) {
return Response.json({ error: { message: error.message } }, { status: 500 })
}
})
*/
}
+523
View File
@@ -0,0 +1,523 @@
[data-page="home"] {
--color-text: hsl(224, 10%, 10%);
--color-text-secondary: hsl(224, 7%, 46%);
--color-text-dimmed: hsl(224, 6%, 63%);
--color-border: hsl(224, 6%, 77%);
}
[data-page="home"] {
@media (prefers-color-scheme: dark) {
--color-text: hsl(0, 0%, 100%);
--color-text-secondary: hsl(224, 6%, 66%);
--color-text-dimmed: hsl(224, 7%, 46%);
--color-border: hsl(224, 6%, 36%);
}
}
[data-page="home"] {
--padding: 3rem;
--vertical-padding: 1.5rem;
--heading-font-size: 1.5625rem;
@media (max-width: 30rem) {
--padding: 1rem;
--vertical-padding: 0.75rem;
--heading-font-size: 1.375rem;
}
font-family: var(--font-mono);
color: var(--color-text);
padding: calc(var(--padding) + 1rem);
a {
color: var(--color-text);
text-decoration: underline;
text-underline-offset: var(--space-0-75);
text-decoration-thickness: 1px;
}
[data-component="content"] {
max-width: 67.5rem;
margin: 0 auto;
border: 2px solid var(--color-border);
}
[data-component="top"] {
padding: var(--padding);
display: flex;
flex-direction: column;
align-items: start;
gap: calc(var(--vertical-padding) / 2);
img {
height: auto;
width: clamp(200px, 70vw, 400px);
}
[data-slot="logo dark"] {
display: none;
}
@media (prefers-color-scheme: dark) {
[data-slot="logo light"] {
display: none;
}
[data-slot="logo dark"] {
display: block;
}
}
[data-slot="title"] {
line-height: 1.25;
font-size: var(--heading-font-size);
text-transform: uppercase;
}
}
[data-component="cta"] {
border-top: 2px solid var(--color-border);
display: grid;
grid-template-columns: auto 1fr auto;
align-items: stretch;
& > div + div {
border-left: 2px solid var(--color-border);
}
[data-slot="left"],
[data-slot="right"] {
text-align: center;
line-height: 1.4;
padding: var(--vertical-padding) 2rem;
text-transform: uppercase;
@media (max-width: 30rem) {
padding-bottom: calc(var(--vertical-padding) + 4px);
}
@media (max-width: 30rem) {
padding-left: 0.5rem;
padding-right: 0.5rem;
}
}
[data-slot="center"] {
padding: var(--vertical-padding) 1rem;
}
@media (max-width: 55rem) {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: auto auto;
[data-slot="left"] {
grid-column: 1;
grid-row: 1;
}
[data-slot="right"] {
grid-column: 2;
grid-row: 1;
}
[data-slot="center"] {
grid-column: 1 / -1;
grid-row: 2;
border-left: none;
border-top: 2px solid var(--color-border);
}
@media (max-width: 22rem) {
grid-template-columns: 1fr;
grid-template-rows: auto auto auto;
[data-slot="left"] {
grid-column: 1;
grid-row: 1;
}
[data-slot="right"] {
grid-column: 1;
grid-row: 2;
border-top: 2px solid var(--color-border);
border-left: none;
}
[data-slot="center"] {
grid-column: 1;
grid-row: 3;
}
}
}
[data-slot="command"] {
all: unset;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--color-text-secondary);
font-size: 1.125rem;
font-family: var(--font-mono);
gap: var(--space-2);
width: 100%;
& > span {
@media (max-width: 24rem) {
font-size: 0.875rem;
}
@media (max-width: 64rem) {
[data-slot="protocol"] {
display: none;
}
}
@media (max-width: 38rem) {
text-align: center;
span:first-child {
display: block;
}
}
}
}
[data-slot="highlight"] {
color: var(--color-text);
font-weight: 500;
}
}
[data-component="features"] {
border-top: 2px solid var(--color-border);
padding: var(--padding);
[data-slot="list"] {
padding-left: var(--space-4);
margin: 0;
list-style: disc;
li {
margin-bottom: var(--space-4);
strong {
text-transform: uppercase;
font-weight: 700;
}
}
li:last-child {
margin-bottom: 0;
}
}
}
[data-component="install"] {
border-top: 2px solid var(--color-border);
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
@media (max-width: 40rem) {
grid-template-columns: 1fr;
grid-template-rows: auto;
}
}
[data-component="method"] {
display: flex;
padding: calc(var(--vertical-padding) / 2) calc(var(--padding) / 2) calc(var(--vertical-padding) / 2 + 0.125rem);
flex-direction: column;
text-align: left;
gap: var(--space-2-5);
@media (max-width: 30rem) {
gap: 0.3125rem;
}
@media (max-width: 40rem) {
text-align: left;
}
&:nth-child(2) {
border-left: 2px solid var(--color-border);
@media (max-width: 40rem) {
border-left: none;
border-top: 2px solid var(--color-border);
}
}
&:nth-child(3) {
border-top: 2px solid var(--color-border);
}
&:nth-child(4) {
border-top: 2px solid var(--color-border);
border-left: 2px solid var(--color-border);
@media (max-width: 40rem) {
border-left: none;
}
}
[data-component="title"] {
letter-spacing: -0.03125rem;
text-transform: uppercase;
font-weight: normal;
font-size: 1rem;
flex-shrink: 0;
color: var(--color-text-dimmed);
@media (max-width: 30rem) {
font-size: 0.75rem;
}
}
[data-slot="button"] {
all: unset;
cursor: pointer;
display: flex;
align-items: center;
color: var(--color-text-secondary);
gap: var(--space-2-5);
font-size: 1rem;
@media (max-width: 24rem) {
font-size: 0.875rem;
}
strong {
color: var(--color-text);
font-weight: 500;
}
@media (max-width: 40rem) {
justify-content: flex-start;
}
@media (max-width: 30rem) {
justify-content: center;
}
}
}
[data-component="screenshots"] {
--images-height: 600px;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: var(--images-height);
border-top: 2px solid var(--color-border);
& > div.left {
display: flex;
grid-row: 1;
grid-column: 1;
}
& > div.right {
display: grid;
grid-template-rows: 1fr 1fr;
grid-row: 1;
grid-column: 2;
border-left: 2px solid var(--color-border);
& > div.row1 {
display: flex;
grid-row: 1;
border-bottom: 2px solid var(--color-border);
height: calc(var(--images-height) / 2);
}
& > div.row2 {
display: flex;
grid-row: 2;
height: calc(var(--images-height) / 2);
}
}
figure {
flex: 1;
display: flex;
flex-direction: column;
gap: calc(var(--padding) / 4);
padding: calc(var(--padding) / 2);
border-width: 0;
border-style: solid;
border-color: var(--color-border);
min-height: 0;
overflow: hidden;
& > div,
figcaption {
display: flex;
align-items: center;
}
& > div {
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
}
a {
display: flex;
flex: 1;
min-height: 0;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
figcaption {
letter-spacing: -0.03125rem;
text-transform: uppercase;
color: var(--color-text-dimmed);
flex-shrink: 0;
@media (max-width: 30rem) {
font-size: 0.75rem;
}
}
}
& > div.left figure {
height: var(--images-height);
box-sizing: border-box;
}
& > div.right figure {
height: calc(var(--images-height) / 2);
box-sizing: border-box;
}
& > div.left img {
width: 100%;
height: 100%;
min-width: 0;
object-fit: contain;
}
& > div.right img {
width: 100%;
height: calc(100% - 2rem);
object-fit: contain;
display: block;
}
@media (max-width: 30rem) {
& {
--images-height: auto;
grid-template-columns: 1fr;
grid-template-rows: auto auto;
}
& > div.left {
grid-row: 1;
grid-column: 1;
}
& > div.right {
grid-row: 2;
grid-column: 1;
border-left: none;
border-top: 2px solid var(--color-border);
& > div.row1,
& > div.row2 {
height: auto;
}
}
& > div.left figure,
& > div.right figure {
height: auto;
}
& > div.left img,
& > div.right img {
width: 100%;
height: auto;
max-height: none;
}
}
}
[data-component="copy-status"] {
@media (max-width: 38rem) {
display: none;
}
[data-slot="copy"] {
display: block;
width: var(--space-4);
height: var(--space-4);
color: var(--color-text-dimmed);
[data-copied] & {
display: none;
}
}
[data-slot="check"] {
display: none;
width: var(--space-4);
height: var(--space-4);
color: var(--color-text);
[data-copied] & {
display: block;
}
}
}
[data-component="footer"] {
border-top: 2px solid var(--color-border);
display: flex;
flex-direction: row;
[data-slot="cell"] {
flex: 1;
text-align: center;
text-transform: uppercase;
padding: var(--vertical-padding) 0.5rem;
}
[data-slot="cell"] + [data-slot="cell"] {
border-left: 2px solid var(--color-border);
}
/* Small desktop: first two columns shrink to content, third expands */
@media (max-width: 57rem) {
[data-slot="cell"]:nth-child(1),
[data-slot="cell"]:nth-child(2) {
flex: 0 0 auto;
padding-left: calc(var(--padding) / 2);
padding-right: calc(var(--padding) / 2);
}
[data-slot="cell"]:nth-child(3) {
flex: 1;
}
}
/* Mobile: third column on its own row */
@media (max-width: 40rem) {
flex-wrap: wrap;
[data-slot="cell"]:nth-child(1),
[data-slot="cell"]:nth-child(2) {
flex: 1;
}
[data-slot="cell"]:nth-child(3) {
flex: 1 0 100%;
border-left: none;
border-top: 2px solid var(--color-border);
}
}
}
}
+198
View File
@@ -0,0 +1,198 @@
import { Title } from "@solidjs/meta"
import { onCleanup, onMount } from "solid-js"
import "./index.css"
import logoLight from "../asset/logo-ornate-light.svg"
import logoDark from "../asset/logo-ornate-dark.svg"
import IMG_SPLASH from "../asset/lander/screenshot-splash.png"
import IMG_VSCODE from "../asset/lander/screenshot-vscode.png"
import IMG_GITHUB from "../asset/lander/screenshot-github.png"
import { IconCopy, IconCheck } from "../component/icon"
import { createAsync, query, redirect } from "@solidjs/router"
import { getActor } from "~/context/auth"
import { withActor } from "~/context/auth.withActor"
import { Account } from "@opencode/cloud-core/account.js"
function CopyStatus() {
return (
<div data-component="copy-status">
<IconCopy data-slot="copy" />
<IconCheck data-slot="check" />
</div>
)
}
const isLoggedIn = query(async () => {
"use server"
const actor = await getActor()
if (actor.type === "account") {
const workspaces = await withActor(() => Account.workspaces())
throw redirect(`/workspace/${workspaces[0].id}`)
}
return false
}, "isLoggedIn")
export default function Home() {
createAsync(() => isLoggedIn(), {
deferStream: true,
})
onMount(() => {
const commands = document.querySelectorAll("[data-copy]")
for (const button of commands) {
const callback = () => {
const text = button.textContent
if (text) {
navigator.clipboard.writeText(text)
button.setAttribute("data-copied", "")
setTimeout(() => {
button.removeAttribute("data-copied")
}, 1500)
}
}
button.addEventListener("click", callback)
onCleanup(() => {
button.removeEventListener("click", callback)
})
}
})
return (
<main data-page="home">
<Title>opencode | AI coding agent built for the terminal</Title>
<div data-component="content">
<section data-component="top">
<img data-slot="logo light" src={logoLight} alt="opencode logo light" />
<img data-slot="logo dark" src={logoDark} alt="opencode logo dark" />
<h1 data-slot="title">The AI coding agent built for the terminal.</h1>
</section>
<section data-component="cta">
<div data-slot="left">
<a href="/docs">Get Started</a>
</div>
<div data-slot="center">
<button data-copy data-slot="command">
<span>
<span>curl -fsSL&nbsp;</span>
<span data-slot="protocol">https://</span>
<span data-slot="highlight">opencode.ai/install</span>
&nbsp;| bash
</span>
<CopyStatus />
</button>
</div>
<div data-slot="right">
<a href="/auth/authorize" target="_self">
Login
</a>
</div>
</section>
<section data-component="features">
<ul data-slot="list">
<li>
<strong>Native TUI</strong>: A responsive, native, themeable terminal UI.
</li>
<li>
<strong>LSP enabled</strong>: Automatically loads the right LSPs for the LLM.
</li>
<li>
<strong>Multi-session</strong>: Start multiple agents in parallel on the same project.
</li>
<li>
<strong>Shareable links</strong>: Share a link to any sessions for reference or to debug.
</li>
<li>
<strong>Claude Pro</strong>: Log in with Anthropic to use your Claude Pro or Max account.
</li>
<li>
<strong>Use any model</strong>: Supports 75+ LLM providers through{" "}
<a href="https://models.dev">Models.dev</a>, including local models.
</li>
</ul>
</section>
<section data-component="install">
<div data-component="method">
<h3 data-component="title">npm</h3>
<button data-copy data-slot="button">
<span>
npm install -g&nbsp;<strong>opencode-ai</strong>
</span>
<CopyStatus />
</button>
</div>
<div data-component="method">
<h3 data-component="title">bun</h3>
<button data-copy data-slot="button">
<span>
bun install -g&nbsp;<strong>opencode-ai</strong>
</span>
<CopyStatus />
</button>
</div>
<div data-component="method">
<h3 data-component="title">homebrew</h3>
<button data-copy data-slot="button">
<span>
brew install&nbsp;<strong>sst/tap/opencode</strong>
</span>
<CopyStatus />
</button>
</div>
<div data-component="method">
<h3 data-component="title">paru</h3>
<button data-copy data-slot="button">
<span>
paru -S&nbsp;<strong>opencode-bin</strong>
</span>
<CopyStatus />
</button>
</div>
</section>
<section data-component="screenshots">
<div class="left">
<figure>
<figcaption>opencode TUI with the tokyonight theme</figcaption>
<a href="/docs/cli">
<img src={IMG_SPLASH} alt="opencode TUI with tokyonight theme" />
</a>
</figure>
</div>
<div class="right">
<div class="row1">
<figure>
<figcaption>opencode in VS Code</figcaption>
<a href="/docs/ide">
<img src={IMG_VSCODE} alt="opencode in VS Code" />
</a>
</figure>
</div>
<div class="row2">
<figure>
<figcaption>opencode in GitHub</figcaption>
<a href="/docs/github">
<img src={IMG_GITHUB} alt="opencode in GitHub" />
</a>
</figure>
</div>
</div>
</section>
<footer data-component="footer">
<div data-slot="cell">
<a href="https://github.com/sst/opencode">GitHub</a>
</div>
<div data-slot="cell">
<a href="https://opencode.ai/discord">Discord</a>
</div>
<div data-slot="cell">
<span>
©2025 <a href="https://anoma.ly">Anomaly Innovations</a>
</span>
</div>
</footer>
</div>
</main>
)
}
+73
View File
@@ -0,0 +1,73 @@
import { Billing } from "@opencode/cloud-core/billing.js"
import type { APIEvent } from "@solidjs/start/server"
import { Database, eq, sql } from "@opencode/cloud-core/drizzle/index.js"
import { BillingTable, PaymentTable } from "@opencode/cloud-core/schema/billing.sql.js"
import { Identifier } from "@opencode/cloud-core/identifier.js"
import { centsToMicroCents } from "@opencode/cloud-core/util/price.js"
import { Actor } from "@opencode/cloud-core/actor.js"
import { Resource } from "@opencode/cloud-resource"
export async function POST(input: APIEvent) {
const body = await Billing.stripe().webhooks.constructEventAsync(
await input.request.text(),
input.request.headers.get("stripe-signature")!,
Resource.STRIPE_WEBHOOK_SECRET.value,
)
console.log(body.type, JSON.stringify(body, null, 2))
if (body.type === "checkout.session.completed") {
const workspaceID = body.data.object.metadata?.workspaceID
const customerID = body.data.object.customer as string
const paymentID = body.data.object.payment_intent as string
const amount = body.data.object.amount_total
if (!workspaceID) throw new Error("Workspace ID not found")
if (!customerID) throw new Error("Customer ID not found")
if (!amount) throw new Error("Amount not found")
if (!paymentID) throw new Error("Payment ID not found")
await Actor.provide("system", { workspaceID }, async () => {
const customer = await Billing.get()
if (customer?.customerID && customer.customerID !== customerID) throw new Error("Customer ID mismatch")
// set customer metadata
if (!customer?.customerID) {
await Billing.stripe().customers.update(customerID, {
metadata: {
workspaceID,
},
})
}
// get payment method for the payment intent
const paymentIntent = await Billing.stripe().paymentIntents.retrieve(paymentID, {
expand: ["payment_method"],
})
const paymentMethod = paymentIntent.payment_method
if (!paymentMethod || typeof paymentMethod === "string") throw new Error("Payment method not expanded")
await Database.transaction(async (tx) => {
await tx
.update(BillingTable)
.set({
balance: sql`${BillingTable.balance} + ${centsToMicroCents(amount)}`,
customerID,
paymentMethodID: paymentMethod.id,
paymentMethodLast4: paymentMethod.card!.last4,
})
.where(eq(BillingTable.workspaceID, workspaceID))
await tx.insert(PaymentTable).values({
workspaceID,
id: Identifier.create("payment"),
amount: centsToMicroCents(amount),
paymentID,
customerID,
})
})
})
}
console.log("finished handling")
return Response.json("ok", { status: 200 })
}
+69
View File
@@ -0,0 +1,69 @@
[data-page="workspace"] {
line-height: 1;
@media (max-width: 30rem) {
padding: var(--space-4);
gap: var(--space-5);
}
/* Workspace Header */
[data-component="workspace-header"] {
position: sticky;
top: 0;
z-index: 100;
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-4) var(--space-4);
border-bottom: 1px solid var(--color-border);
background-color: var(--color-bg);
@media (max-width: 30rem) {
padding: 0 var(--space-4);
margin: calc(-1 * var(--space-4));
margin-bottom: var(--space-5);
}
}
[data-slot="header-brand"] {
flex: 0 0 auto;
padding-top: 4px;
svg {
width: 138px;
}
[data-component="site-title"] {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--color-text);
text-decoration: none;
letter-spacing: -0.02em;
}
}
[data-slot="header-actions"] {
display: flex;
gap: var(--space-4);
align-items: center;
font-size: var(--font-size-sm);
span {
color: var(--color-text-muted);
}
a,
button {
appearance: none;
background: none;
border: none;
cursor: pointer;
padding: 0;
color: var(--color-text);
text-decoration: underline;
text-underline-offset: var(--space-0-75);
text-decoration-thickness: 1px;
text-transform: uppercase;
}
}
}
+54
View File
@@ -0,0 +1,54 @@
import "./workspace.css"
import { useAuthSession } from "~/context/auth.session"
import { IconLogo } from "../component/icon"
import { withActor } from "~/context/auth.withActor"
import "./workspace.css"
import { query, action, redirect, createAsync, RouteSectionProps } from "@solidjs/router"
import { User } from "@opencode/cloud-core/user.js"
import { Actor } from "@opencode/cloud-core/actor.js"
const getUserInfo = query(async () => {
"use server"
return withActor(async () => {
const actor = Actor.assert("user")
const user = await User.fromID(actor.properties.userID)
return { user }
})
}, "userInfo")
const logout = action(async () => {
"use server"
const auth = await useAuthSession()
const current = auth.data.current
if (current)
await auth.update((val) => {
delete val.account[current]
return val
})
return redirect("/")
})
export default function WorkspaceLayout(props: RouteSectionProps) {
const userInfo = createAsync(() => getUserInfo(), {
deferStream: true,
})
return (
<main data-page="workspace">
<header data-component="workspace-header">
<div data-slot="header-brand">
<a href="/" data-component="site-title">
<IconLogo />
</a>
</div>
<div data-slot="header-actions">
<span>{userInfo()?.user.email}</span>
<form action={logout} method="post">
<button type="submit" formaction={logout}>Logout</button>
</form>
</div>
</header>
<div data-slot="content">{props.children}</div>
</main>
)
}
+474
View File
@@ -0,0 +1,474 @@
/* Root container */
[data-slot="root"] {
max-width: 64rem;
padding: var(--space-10) var(--space-4);
margin: 0 auto;
width: 100%;
display: flex;
flex-direction: column;
gap: var(--space-10);
[data-slot="sections"] {
display: flex;
flex-direction: column;
gap: var(--space-16);
section {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
section:not(:last-child) {
border-bottom: 1px solid var(--color-border);
padding-bottom: var(--space-16);
}
}
/* Common elements */
button {
padding: var(--space-3) var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
background-color: var(--color-bg);
color: var(--color-text);
font-size: var(--font-size-sm);
font-family: var(--font-sans);
font-weight: 500;
text-transform: uppercase;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background-color: var(--color-surface-hover);
border-color: var(--color-accent);
}
&:active {
transform: translateY(1px);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
&:hover {
background-color: var(--color-bg);
border-color: var(--color-border);
transform: none;
}
}
&[data-color="primary"] {
background-color: var(--color-primary);
border-color: var(--color-primary);
color: var(--color-primary-text);
&:hover {
background-color: var(--color-primary-hover);
border-color: var(--color-primary-hover);
}
}
&[data-color="ghost"] {
background-color: transparent;
border-color: transparent;
color: var(--color-text-muted);
&:hover {
background-color: var(--color-surface-hover);
border-color: var(--color-border);
color: var(--color-text);
}
}
}
a {
color: var(--color-text);
text-decoration: underline;
text-underline-offset: var(--space-0-75);
text-decoration-thickness: 1px;
}
[data-slot="empty-state"] {
padding: var(--space-20) var(--space-6);
text-align: center;
border: 1px dashed var(--color-border);
border-radius: var(--border-radius-sm);
display: flex;
flex-direction: column;
gap: var(--space-2);
p {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
margin: 0;
}
}
/* Title section */
[data-slot="title-section"] {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding-bottom: var(--space-8);
border-bottom: 1px solid var(--color-border);
h1 {
font-size: var(--font-size-2xl);
font-weight: 500;
line-height: 1.2;
letter-spacing: -0.03125rem;
margin: 0;
text-transform: uppercase;
@media (max-width: 30rem) {
font-size: var(--font-size-xl);
line-height: 1.25;
}
}
p {
font-size: var(--font-size-md);
color: var(--color-text-muted);
a {
color: var(--color-text-muted);
}
}
}
/* Section titles */
[data-slot="section-title"] {
display: flex;
flex-direction: column;
gap: var(--space-1);
h2 {
font-size: var(--font-size-md);
font-weight: 600;
line-height: 1.2;
letter-spacing: -0.03125rem;
margin: 0;
color: var(--color-text-secondary);
text-transform: uppercase;
@media (max-width: 30rem) {
font-size: var(--font-size-lg);
line-height: 1.25;
}
}
p {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
}
/* API Keys Section */
[data-slot="api-keys-section"] {
[data-slot="create-form"] {
display: flex;
gap: var(--space-3);
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
input {
flex: 1;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
background-color: var(--color-bg);
color: var(--color-text);
font-size: var(--font-size-sm);
font-family: var(--font-mono);
&:focus {
outline: none;
border-color: var(--color-accent);
}
&::placeholder {
color: var(--color-text-disabled);
}
}
[data-slot="form-actions"] {
display: flex;
gap: var(--space-2);
}
}
[data-slot="api-keys-table"] {
overflow-x: auto;
}
[data-slot="api-keys-table-element"] {
width: 100%;
border-collapse: collapse;
font-size: var(--font-size-sm);
thead {
border-bottom: 1px solid var(--color-border);
}
th {
padding: var(--space-3) var(--space-4);
text-align: left;
font-weight: normal;
color: var(--color-text-muted);
text-transform: uppercase;
}
td {
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border-muted);
color: var(--color-text-muted);
font-family: var(--font-mono);
&[data-slot="key-name"] {
color: var(--color-text);
font-family: var(--font-sans);
font-weight: 500;
}
&[data-slot="key-value"] {
font-family: var(--font-mono);
div {
cursor: pointer;
display: flex;
align-items: center;
gap: var(--space-2);
}
}
&[data-slot="key-date"] {
color: var(--color-text);
}
&[data-slot="key-actions"] {
font-family: var(--font-sans);
}
}
tbody tr {
&:last-child td {
border-bottom: none;
}
}
@media (max-width: 40rem) {
th,
td {
padding: var(--space-2) var(--space-3);
font-size: var(--font-size-xs);
}
th {
&:nth-child(3) /* Date */ {
display: none;
}
}
td {
&:nth-child(3) /* Date */ {
display: none;
}
}
}
}
}
/* Balance Section */
[data-slot="balance-section"] {
[data-slot="balance"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
min-width: 14.5rem;
width: fit-content;
[data-slot="amount"] {
padding: var(--space-3-5) var(--space-4);
background-color: var(--color-bg-surface);
border-radius: var(--border-radius-sm);
display: flex;
align-items: baseline;
gap: var(--space-1);
justify-content: flex-end;
&.danger {
[data-slot="value"] {
color: var(--color-danger);
}
}
[data-slot="currency"] {
position: relative;
bottom: 2px;
font-size: var(--font-size-lg);
color: var(--color-text-muted);
font-weight: 400;
}
[data-slot="value"] {
font-size: var(--font-size-3xl);
font-weight: 500;
color: var(--color-text);
}
}
}
}
/* Payments Section */
[data-slot="payments-section"] {
[data-slot="payments-table"] {
overflow-x: auto;
}
[data-slot="payments-table-element"] {
width: 100%;
border-collapse: collapse;
font-size: var(--font-size-sm);
thead {
border-bottom: 1px solid var(--color-border);
}
th {
padding: var(--space-3) var(--space-4);
text-align: left;
font-weight: normal;
color: var(--color-text-muted);
text-transform: uppercase;
}
td {
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border-muted);
color: var(--color-text-muted);
font-family: var(--font-mono);
&[data-slot="payment-date"] {
color: var(--color-text);
}
&[data-slot="payment-id"] {
font-family: var(--font-mono);
font-weight: 400;
color: var(--color-text-muted);
max-width: 200px;
word-break: break-word;
}
&[data-slot="payment-amount"] {
color: var(--color-text);
}
}
tbody tr {
&:last-child td {
border-bottom: none;
}
}
@media (max-width: 40rem) {
th,
td {
padding: var(--space-2) var(--space-3);
font-size: var(--font-size-xs);
}
th {
&:nth-child(2) /* Payment ID */ {
display: none;
}
}
td {
&:nth-child(2) /* Payment ID */ {
display: none;
}
}
}
}
}
/* Usage Section */
[data-slot="usage-section"] {
[data-slot="usage-table"] {
overflow-x: auto;
}
[data-slot="usage-table-element"] {
width: 100%;
border-collapse: collapse;
font-size: var(--font-size-sm);
thead {
border-bottom: 1px solid var(--color-border);
}
th {
padding: var(--space-3) var(--space-4);
text-align: left;
font-weight: normal;
color: var(--color-text-muted);
text-transform: uppercase;
}
td {
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border-muted);
color: var(--color-text-muted);
font-family: var(--font-mono);
&[data-slot="usage-date"] {
color: var(--color-text);
}
&[data-slot="usage-model"] {
font-family: var(--font-sans);
font-weight: 400;
color: var(--color-text-secondary);
max-width: 200px;
word-break: break-word;
}
&[data-slot="usage-cost"] {
color: var(--color-text);
}
}
tbody tr {
&:last-child td {
border-bottom: none;
}
}
@media (max-width: 40rem) {
th,
td {
padding: var(--space-2) var(--space-3);
font-size: var(--font-size-xs);
}
th {
&:nth-child(2) /* Model */ {
display: none;
}
}
td {
&:nth-child(2) /* Model */ {
display: none;
}
}
}
}
}
}
+407
View File
@@ -0,0 +1,407 @@
import { Billing } from "@opencode/cloud-core/billing.js"
import { Key } from "@opencode/cloud-core/key.js"
import { action, createAsync, query, useAction, useSubmission, json } from "@solidjs/router"
import { createSignal, For, onMount, Show } from "solid-js"
import { getActor } from "~/context/auth"
import { withActor } from "~/context/auth.withActor"
import { IconCopy, IconCheck } from "~/component/icon"
import "./[id].css"
import { User } from "@opencode/cloud-core/user.js"
import { Actor } from "@opencode/cloud-core/actor.js"
/////////////////////////////////////
// Keys related queries and actions
/////////////////////////////////////
const listKeys = query(() => {
"use server"
return withActor(() => Key.list())
}, "key.list")
const createKey = action(async (name: string) => {
"use server"
return json(
withActor(() => Key.create({ name })),
{ revalidate: listKeys.key },
)
}, "key.create")
const removeKey = action(async (id: string) => {
"use server"
return json(
withActor(() => Key.remove({ id })),
{ revalidate: listKeys.key },
)
}, "key.remove")
/////////////////////////////////////
// Billing related queries and actions
/////////////////////////////////////
const getBillingInfo = query(async () => {
"use server"
return withActor(async () => {
const actor = Actor.assert("user")
const [user, billing, payments, usage] = await Promise.all([
User.fromID(actor.properties.userID),
Billing.get(),
Billing.payments(),
Billing.usages(),
])
return { user, billing, payments, usage }
})
}, "billingInfo")
const createCheckoutUrl = action(async (successUrl: string, cancelUrl: string) => {
"use server"
return withActor(() => Billing.generateCheckoutUrl({ successUrl, cancelUrl }))
}, "checkoutUrl")
const createPortalUrl = action(async (returnUrl: string) => {
"use server"
return withActor(() => Billing.generatePortalUrl({ returnUrl }))
}, "portalUrl")
export default function () {
/////////////////
// Keys section
/////////////////
const keys = createAsync(() => listKeys(), {
deferStream: true,
})
const createKeyAction = useAction(createKey)
const removeKeyAction = useAction(removeKey)
const createKeySubmission = useSubmission(createKey)
const [showCreateForm, setShowCreateForm] = createSignal(false)
const [keyName, setKeyName] = createSignal("")
const [copiedKeyId, setCopiedKeyId] = createSignal<string | null>(null)
const formatDate = (date: Date) => {
return date.toLocaleDateString()
}
const formatDateForTable = (date: Date) => {
const options: Intl.DateTimeFormatOptions = {
day: "numeric",
month: "short",
hour: "numeric",
minute: "2-digit",
hour12: true,
}
return date.toLocaleDateString("en-GB", options).replace(",", ",")
}
const formatDateUTC = (date: Date) => {
const options: Intl.DateTimeFormatOptions = {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
timeZoneName: "short",
timeZone: "UTC",
}
return date.toLocaleDateString("en-US", options)
}
const formatKey = (key: string) => {
if (key.length <= 11) return key
return `${key.slice(0, 7)}...${key.slice(-4)}`
}
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text)
} catch (error) {
console.error("Failed to copy to clipboard:", error)
}
}
const copyKeyToClipboard = async (text: string, keyId: string) => {
try {
await navigator.clipboard.writeText(text)
setCopiedKeyId(keyId)
setTimeout(() => setCopiedKeyId(null), 1500)
} catch (error) {
console.error("Failed to copy to clipboard:", error)
}
}
const handleCreateKey = async () => {
if (!keyName().trim()) return
try {
await createKeyAction(keyName().trim())
setKeyName("")
setShowCreateForm(false)
} catch (error) {
console.error("Failed to create API key:", error)
}
}
const handleDeleteKey = async (keyId: string) => {
if (!confirm("Are you sure you want to delete this API key?")) {
return
}
try {
await removeKeyAction(keyId)
} catch (error) {
console.error("Failed to delete API key:", error)
}
}
/////////////////
// Billing section
/////////////////
const billingInfo = createAsync(() => getBillingInfo(), {
deferStream: true,
})
const createCheckoutUrlAction = useAction(createCheckoutUrl)
const createCheckoutUrlSubmission = useSubmission(createCheckoutUrl)
const handleBuyCredits = async () => {
try {
const baseUrl = window.location.href
const checkoutUrl = await createCheckoutUrlAction(baseUrl, baseUrl)
if (checkoutUrl) {
window.location.href = checkoutUrl
}
} catch (error) {
console.error("Failed to get checkout URL:", error)
}
}
return (
<div data-slot="root">
{/* Title */}
<section data-slot="title-section">
<h1>Gateway</h1>
<p>
Coding models optimized for use with opencode. <a href="/docs">Learn more</a>.
</p>
</section>
<div data-slot="sections">
{/* API Keys Section */}
<section data-slot="api-keys-section">
<div data-slot="section-title">
<h2>API Keys</h2>
<p>Manage your API keys for accessing opencode services.</p>
</div>
<Show
when={!showCreateForm()}
fallback={
<div data-slot="create-form">
<input
data-component="input"
type="text"
placeholder="Enter key name"
value={keyName()}
onInput={(e) => setKeyName(e.currentTarget.value)}
onKeyPress={(e) => e.key === "Enter" && handleCreateKey()}
/>
<div data-slot="form-actions">
<button
data-color="ghost"
onClick={() => {
setShowCreateForm(false)
setKeyName("")
}}
>
Cancel
</button>
<button
data-color="primary"
disabled={createKeySubmission.pending || !keyName().trim()}
onClick={handleCreateKey}
>
{createKeySubmission.pending ? "Creating..." : "Create"}
</button>
</div>
</div>
}
>
<button
data-color="primary"
onClick={() => {
console.log("clicked")
setShowCreateForm(true)
}}
>
Create API Key
</button>
</Show>
<div data-slot="api-keys-table">
<Show
when={keys()?.length}
fallback={
<div data-slot="empty-state">
<p>Create an opencode Gateway API key</p>
</div>
}
>
<table data-slot="api-keys-table-element">
<thead>
<tr>
<th>Name</th>
<th>Key</th>
<th>Created</th>
<th></th>
</tr>
</thead>
<tbody>
<For each={keys()!}>
{(key) => (
<tr>
<td data-slot="key-name">{key.name}</td>
<td data-slot="key-value">
<div onClick={() => copyKeyToClipboard(key.key, key.id)} title="Click to copy API key">
<span>{formatKey(key.key)}</span>
<Show
when={copiedKeyId() === key.id}
fallback={<IconCopy style={{ width: "14px", height: "14px" }} />}
>
<IconCheck style={{ width: "14px", height: "14px" }} />
</Show>
</div>
</td>
<td data-slot="key-date" title={formatDateUTC(key.timeCreated)}>
{formatDateForTable(key.timeCreated)}
</td>
<td data-slot="key-actions">
<button data-color="ghost" onClick={() => handleDeleteKey(key.id)} title="Delete API key">
Delete
</button>
</td>
</tr>
)}
</For>
</tbody>
</table>
</Show>
</div>
</section>
{/* Balance Section */}
<section data-slot="balance-section">
<div data-slot="section-title">
<h2>Balance</h2>
<p>Add credits to your account.</p>
</div>
<div data-slot="balance">
<div
data-slot="amount"
classList={{
danger: (() => {
const balanceStr = ((billingInfo()?.billing?.balance ?? 0) / 100000000).toFixed(2)
return balanceStr === "0.00" || balanceStr === "-0.00"
})(),
}}
>
<span data-slot="currency">$</span>
<span data-slot="value">
{(() => {
const balanceStr = ((billingInfo()?.billing?.balance ?? 0) / 100000000).toFixed(2)
return balanceStr === "-0.00" ? "0.00" : balanceStr
})()}
</span>
</div>
<button data-color="primary" disabled={createCheckoutUrlSubmission.pending} onClick={handleBuyCredits}>
{createCheckoutUrlSubmission.pending ? "Loading..." : "Buy Credits"}
</button>
</div>
</section>
{/* Usage Section */}
<section data-slot="usage-section">
<div data-slot="section-title">
<h2>Usage History</h2>
<p>Recent API usage and costs.</p>
</div>
<div data-slot="usage-table">
<Show
when={billingInfo() && billingInfo()!.usage.length > 0}
fallback={
<div data-slot="empty-state">
<p>Make your first API call to get started.</p>
</div>
}
>
<table data-slot="usage-table-element">
<thead>
<tr>
<th>Date</th>
<th>Model</th>
<th>Tokens</th>
<th>Cost</th>
</tr>
</thead>
<tbody>
<For each={billingInfo()!.usage}>
{(usage) => {
const totalTokens = usage.inputTokens + usage.outputTokens + (usage.reasoningTokens || 0)
const date = new Date(usage.timeCreated)
return (
<tr>
<td data-slot="usage-date" title={formatDateUTC(date)}>
{formatDateForTable(date)}
</td>
<td data-slot="usage-model">{usage.model}</td>
<td data-slot="usage-tokens">{totalTokens.toLocaleString()}</td>
<td data-slot="usage-cost">${((usage.cost ?? 0) / 100000000).toFixed(4)}</td>
</tr>
)
}}
</For>
</tbody>
</table>
</Show>
</div>
</section>
{/* Payments Section */}
<Show when={billingInfo() && billingInfo()!.payments.length > 0}>
<section data-slot="payments-section">
<div data-slot="section-title">
<h2>Payments History</h2>
<p>Recent payment transactions.</p>
</div>
<div data-slot="payments-table">
<table data-slot="payments-table-element">
<thead>
<tr>
<th>Date</th>
<th>Payment ID</th>
<th>Amount</th>
</tr>
</thead>
<tbody>
<For each={billingInfo()!.payments}>
{(payment) => {
const date = new Date(payment.timeCreated)
return (
<tr>
<td data-slot="payment-date" title={formatDateUTC(date)}>
{formatDateForTable(date)}
</td>
<td data-slot="payment-id">{payment.id}</td>
<td data-slot="payment-amount">${((payment.amount ?? 0) / 100000000).toFixed(2)}</td>
</tr>
)
}}
</For>
</tbody>
</table>
</div>
</section>
</Show>
</div>
</div>
)
}
+9
View File
@@ -0,0 +1,9 @@
html {
line-height: 1;
background-color: var(--color-bg);
color: var(--color-text);
}
body {
font-family: var(--font-sans);
}
+102
View File
@@ -0,0 +1,102 @@
[data-component="button"] {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
border: 1px solid transparent;
border-radius: var(--space-2);
font-family: var(--font-sans);
font-size: var(--font-size-md);
font-weight: 500;
line-height: 1.25;
cursor: pointer;
transition: all 0.2s ease-in-out;
text-decoration: none;
user-select: none;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&:focus {
outline: none;
box-shadow: 0 0 0 2px var(--color-primary);
}
&[data-color="primary"] {
background-color: var(--color-primary);
color: var(--color-primary-text);
border-color: var(--color-primary);
&:hover:not(:disabled) {
background-color: var(--color-primary-hover);
border-color: var(--color-primary-hover);
}
&:active:not(:disabled) {
background-color: var(--color-primary-active);
border-color: var(--color-primary-active);
}
}
&[data-color="danger"] {
background-color: var(--color-danger);
color: var(--color-danger-text);
border-color: var(--color-danger);
&:hover:not(:disabled) {
background-color: var(--color-danger-hover);
border-color: var(--color-danger-hover);
}
&:active:not(:disabled) {
background-color: var(--color-danger-active);
border-color: var(--color-danger-active);
}
&:focus {
box-shadow: 0 0 0 2px var(--color-danger);
}
}
&[data-color="warning"] {
background-color: var(--color-warning);
color: var(--color-warning-text);
border-color: var(--color-warning);
&:hover:not(:disabled) {
background-color: var(--color-warning-hover);
border-color: var(--color-warning-hover);
}
&:active:not(:disabled) {
background-color: var(--color-warning-active);
border-color: var(--color-warning-active);
}
&:focus {
box-shadow: 0 0 0 2px var(--color-warning);
}
}
&[data-size="small"] {
padding: var(--space-2) var(--space-3);
font-size: var(--font-size-sm);
gap: var(--space-1-5);
}
&[data-size="large"] {
padding: var(--space-4) var(--space-6);
font-size: var(--font-size-lg);
gap: var(--space-3);
}
[data-slot="icon"] {
display: flex;
align-items: center;
width: 1em;
height: 1em;
}
}
+8
View File
@@ -0,0 +1,8 @@
@import "./token/color.css";
@import "./token/font.css";
@import "./token/space.css";
@import "./component/button.css";
@import "./reset.css";
@import "./base.css";
+76
View File
@@ -0,0 +1,76 @@
/* 1. Use a more-intuitive box-sizing model */
*,
*::before,
*::after {
box-sizing: border-box;
}
/* 2. Remove default margin */
* {
margin: 0;
}
/* 3. Enable keyword animations */
@media (prefers-reduced-motion: no-preference) {
html {
interpolate-size: allow-keywords;
}
}
body {
/* 4. Add accessible line-height */
line-height: 1.5;
/* 5. Improve text rendering */
-webkit-font-smoothing: antialiased;
}
/* 6. Improve media defaults */
img,
picture,
video,
canvas,
svg {
display: block;
max-width: 100%;
}
/* 7. Inherit fonts for form controls */
input,
button,
textarea,
select {
font: inherit;
}
/* 8. Avoid text overflows */
p,
h1,
h2,
h3,
h4,
h5,
h6 {
overflow-wrap: break-word;
}
/* 9. Improve line wrapping */
p {
text-wrap: pretty;
}
h1,
h2,
h3,
h4,
h5,
h6 {
text-wrap: balance;
}
/*
10. Create a root stacking context
*/
#root,
#__next {
isolation: isolate;
}
+91
View File
@@ -0,0 +1,91 @@
:root {
--color-white: #ffffff;
--color-black: #000000;
/* Default light theme colors */
--color-bg: #ffffff;
--color-bg-surface: #f5f5f7;
--color-bg-elevated: #ffffff;
--color-text: #1d1d1f;
--color-text-secondary: #424245;
--color-text-muted: #6e6e73;
--color-text-disabled: #86868b;
--color-accent: #007aff;
--color-accent-hover: #0056b3;
--color-accent-active: #004085;
--color-success: #30d158;
--color-warning: #ff9f0a;
--color-danger: #ff3b30;
--color-border: #d2d2d7;
--color-border-muted: #e5e5ea;
/* Button colors */
--color-primary: var(--color-accent);
--color-primary-hover: var(--color-accent-hover);
--color-primary-active: var(--color-accent-active);
--color-primary-text: #ffffff;
--color-danger: #ff3b30;
--color-danger-hover: #d70015;
--color-danger-active: #a50011;
--color-danger-text: #ffffff;
--color-warning: #ff9f0a;
--color-warning-hover: #cc7f08;
--color-warning-active: #995f06;
--color-warning-text: #000000;
/* Surface colors */
--color-surface: var(--color-bg-surface);
--color-surface-hover: var(--color-bg-elevated);
--color-surface-border: var(--color-border);
}
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #0c0c0e;
--color-bg-surface: #161618;
--color-bg-elevated: #1c1c1f;
--color-text: #ffffff;
--color-text-secondary: #c7c7cc;
--color-text-muted: #a1a1a6;
--color-text-disabled: #68686f;
--color-accent: #007aff;
--color-accent-hover: #0056b3;
--color-accent-active: #004085;
--color-success: #30d158;
--color-warning: #ff9f0a;
--color-danger: #ff453a;
--color-border: #38383a;
--color-border-muted: #2c2c2e;
/* Button colors */
--color-primary: var(--color-accent);
--color-primary-hover: var(--color-accent-hover);
--color-primary-active: var(--color-accent-active);
--color-primary-text: #ffffff;
--color-danger: #ff453a;
--color-danger-hover: #d70015;
--color-danger-active: #a50011;
--color-danger-text: #ffffff;
--color-warning: #ff9f0a;
--color-warning-hover: #cc7f08;
--color-warning-active: #995f06;
--color-warning-text: #000000;
/* Surface colors */
--color-surface: var(--color-bg-surface);
--color-surface-hover: var(--color-bg-elevated);
--color-surface-border: var(--color-border);
}
}
@@ -1,4 +1,4 @@
:root {
body {
--font-size-2xs: 0.6875rem;
--font-size-xs: 0.75rem;
--font-size-sm: 0.8125rem;
@@ -13,8 +13,7 @@
--font-size-7xl: 4.5rem;
--font-size-8xl: 6rem;
--font-size-9xl: 8rem;
--font-mono: IBM Plex Mono, monospace;
--font-sans: Rubik, sans-serif;
--font-line-height: 1.75;
--font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--font-sans: var(--font-mono);
}
@@ -1,7 +1,8 @@
:root {
body {
--space-0: 0;
--space-px: 1px;
--space-0-5: 0.125rem;
--space-0-75: 0.1875rem;
--space-1: 0.25rem;
--space-1-5: 0.375rem;
--space-2: 0.5rem;
@@ -20,6 +21,9 @@
--space-12: 3rem;
--space-14: 3.5rem;
--space-16: 4rem;
--space-17: 4.25rem;
--space-18: 4.5rem;
--space-19: 4.75rem;
--space-20: 5rem;
--space-24: 6rem;
--space-28: 7rem;
@@ -35,4 +39,8 @@
--space-72: 18rem;
--space-80: 20rem;
--space-96: 24rem;
--border-radius-sm: 0.1875rem;
--border-radius-md: 0.3125rem;
--border-radius-lg: 0.5rem;
}
View File
+24
View File
@@ -0,0 +1,24 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
"allowJs": true,
"strict": true,
"noEmit": true,
"types": [
"vinxi/types/client"
],
"isolatedModules": true,
"paths": {
"~/*": [
"./src/*"
]
}
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { Resource } from "@opencode/cloud-resource"
import { defineConfig } from "drizzle-kit"
import { Resource } from "sst"
export default defineConfig({
out: "./migrations/",
+2 -1
View File
@@ -1,11 +1,12 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/cloud-core",
"version": "0.5.2",
"version": "0.6.1",
"private": true,
"type": "module",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@opencode/cloud-resource": "workspace:*",
"drizzle-orm": "0.41.0",
"postgres": "3.4.7",
"stripe": "18.0.0",
-1
View File
@@ -20,7 +20,6 @@ export namespace Actor {
properties: {
userID: string
workspaceID: string
email: string
}
}
+93 -2
View File
@@ -1,12 +1,13 @@
import { Resource } from "sst"
import { Stripe } from "stripe"
import { Database, eq, sql } from "./drizzle"
import { BillingTable, UsageTable } from "./schema/billing.sql"
import { BillingTable, PaymentTable, UsageTable } from "./schema/billing.sql"
import { Actor } from "./actor"
import { fn } from "./util/fn"
import { z } from "zod"
import { Identifier } from "./identifier"
import { centsToMicroCents } from "./util/price"
import { User } from "./user"
import { Resource } from "@opencode/cloud-resource"
export namespace Billing {
export const stripe = () =>
@@ -29,6 +30,28 @@ export namespace Billing {
)
}
export const payments = async () => {
return await Database.use((tx) =>
tx
.select()
.from(PaymentTable)
.where(eq(PaymentTable.workspaceID, Actor.workspace()))
.orderBy(sql`${PaymentTable.timeCreated} DESC`)
.limit(100),
)
}
export const usages = async () => {
return await Database.use((tx) =>
tx
.select()
.from(UsageTable)
.where(eq(UsageTable.workspaceID, Actor.workspace()))
.orderBy(sql`${UsageTable.timeCreated} DESC`)
.limit(100),
)
}
export const consume = fn(
z.object({
requestID: z.string().optional(),
@@ -68,4 +91,72 @@ export namespace Billing {
})
},
)
export const generateCheckoutUrl = fn(
z.object({
successUrl: z.string(),
cancelUrl: z.string(),
}),
async (input) => {
const account = Actor.assert("user")
const { successUrl, cancelUrl } = input
const user = await User.fromID(account.properties.userID)
const customer = await Billing.get()
const session = await Billing.stripe().checkout.sessions.create({
mode: "payment",
line_items: [
{
price_data: {
currency: "usd",
product_data: {
name: "opencode credits",
},
unit_amount: 2000, // $20 minimum
},
quantity: 1,
},
],
payment_intent_data: {
setup_future_usage: "on_session",
},
...(customer.customerID
? { customer: customer.customerID }
: {
customer_email: user.email,
customer_creation: "always",
}),
metadata: {
workspaceID: Actor.workspace(),
},
currency: "usd",
payment_method_types: ["card"],
success_url: successUrl,
cancel_url: cancelUrl,
})
return session.url
},
)
export const generatePortalUrl = fn(
z.object({
returnUrl: z.string(),
}),
async (input) => {
const { returnUrl } = input
const customer = await Billing.get()
if (!customer?.customerID) {
throw new Error("No stripe customer ID")
}
const session = await Billing.stripe().billingPortal.sessions.create({
customer: customer.customerID,
return_url: returnUrl,
})
return session.url
},
)
}
+6 -4
View File
@@ -1,9 +1,9 @@
import { drizzle } from "drizzle-orm/postgres-js"
import { Resource } from "sst"
import { Resource } from "@opencode/cloud-resource"
export * from "drizzle-orm"
import postgres from "postgres"
function createClient() {
const init = () => {
const client = postgres({
idle_timeout: 30000,
connect_timeout: 30000,
@@ -15,16 +15,18 @@ function createClient() {
ssl: {
rejectUnauthorized: false,
},
max: 1,
max: 6,
})
return drizzle(client, {})
}
const createClient = init
import { PgTransaction, type PgTransactionConfig } from "drizzle-orm/pg-core"
import type { ExtractTablesWithRelations } from "drizzle-orm"
import type { PostgresJsQueryResultHKT } from "drizzle-orm/postgres-js"
import { Context } from "../context"
import { memo } from "../util/memo"
export namespace Database {
export type Transaction = PgTransaction<
+79
View File
@@ -0,0 +1,79 @@
import { z } from "zod"
import { fn } from "./util/fn"
import { Actor } from "./actor"
import { and, Database, eq, sql } from "./drizzle"
import { Identifier } from "./identifier"
import { KeyTable } from "./schema/key.sql"
export namespace Key {
export const list = async () => {
const user = Actor.assert("user")
const keys = await Database.use((tx) =>
tx
.select({
id: KeyTable.id,
name: KeyTable.name,
key: KeyTable.key,
userID: KeyTable.userID,
timeCreated: KeyTable.timeCreated,
timeUsed: KeyTable.timeUsed,
})
.from(KeyTable)
.where(eq(KeyTable.workspaceID, user.properties.workspaceID))
.orderBy(sql`${KeyTable.timeCreated} DESC`),
)
return keys
}
export const create = fn(z.object({ name: z.string().min(1).max(255) }), async (input) => {
const user = Actor.assert("user")
const { name } = input
// Generate secret key: sk- + 64 random characters (upper, lower, numbers)
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
let randomPart = ""
for (let i = 0; i < 64; i++) {
randomPart += chars.charAt(Math.floor(Math.random() * chars.length))
}
const secretKey = `sk-${randomPart}`
const keyRecord = await Database.use((tx) =>
tx
.insert(KeyTable)
.values({
id: Identifier.create("key"),
workspaceID: user.properties.workspaceID,
userID: user.properties.userID,
name,
key: secretKey,
timeUsed: null,
})
.returning(),
)
return {
key: secretKey,
id: keyRecord[0].id,
name: keyRecord[0].name,
created: keyRecord[0].timeCreated,
}
})
export const remove = fn(z.object({ id: z.string() }), async (input) => {
const user = Actor.assert("user")
const { id } = input
const result = await Database.use((tx) =>
tx
.delete(KeyTable)
.where(and(eq(KeyTable.id, id), eq(KeyTable.workspaceID, user.properties.workspaceID)))
.returning({ id: KeyTable.id }),
)
if (result.length === 0) {
throw new Error("Key not found")
}
return { id: result[0].id }
})
}
+18
View File
@@ -0,0 +1,18 @@
import { z } from "zod"
import { eq } from "drizzle-orm"
import { fn } from "./util/fn"
import { Database } from "./drizzle"
import { UserTable } from "./schema/user.sql"
export namespace User {
export const fromID = fn(z.string(), async (id) =>
Database.transaction(async (tx) => {
return tx
.select()
.from(UserTable)
.where(eq(UserTable.id, id))
.execute()
.then((rows) => rows[0])
}),
)
}
+11
View File
@@ -0,0 +1,11 @@
export function memo<T>(fn: () => T) {
let value: T | undefined
let loaded = false
return (): T => {
if (loaded) return value as T
loaded = true
value = fn()
return value as T
}
}
+10
View File
@@ -7,6 +7,7 @@ import { Identifier } from "./identifier"
import { UserTable } from "./schema/user.sql"
import { BillingTable } from "./schema/billing.sql"
import { WorkspaceTable } from "./schema/workspace.sql"
import { Key } from "./key"
export namespace Workspace {
export const create = fn(z.void(), async () => {
@@ -28,6 +29,15 @@ export namespace Workspace {
balance: centsToMicroCents(100),
})
})
await Actor.provide(
"system",
{
workspaceID,
},
async () => {
await Key.create({ name: "Default API Key" })
},
)
return workspaceID
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/cloud-function",
"version": "0.5.2",
"version": "0.6.1",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+17 -2
View File
@@ -1,12 +1,15 @@
import { Resource } from "sst"
import { z } from "zod"
import { issuer } from "@openauthjs/openauth"
import type { Theme } from "@openauthjs/openauth/ui/theme"
import { createSubjects } from "@openauthjs/openauth/subject"
import { CodeProvider } from "@openauthjs/openauth/provider/code"
import { THEME_OPENAUTH } from "@openauthjs/openauth/ui/theme"
import { GithubProvider } from "@openauthjs/openauth/provider/github"
import { GoogleOidcProvider } from "@openauthjs/openauth/provider/google"
import { CloudflareStorage } from "@openauthjs/openauth/storage/cloudflare"
import { Account } from "@opencode/cloud-core/account.js"
import { Workspace } from "@opencode/cloud-core/workspace.js"
import { Actor } from "@opencode/cloud-core/actor.js"
import { Resource } from "@opencode/cloud-resource"
type Env = {
AuthStorage: KVNamespace
@@ -23,9 +26,15 @@ export const subjects = createSubjects({
}),
})
const MY_THEME: Theme = {
...THEME_OPENAUTH,
logo: "https://opencode.ai/favicon.svg",
}
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
return issuer({
theme: MY_THEME,
providers: {
github: GithubProvider({
clientID: Resource.GITHUB_CLIENT_ID_CONSOLE.value,
@@ -117,6 +126,12 @@ export default {
email: email!,
})
}
await Actor.provide("account", { accountID, email }, async () => {
const workspaces = await Account.workspaces()
if (workspaces.length === 0) {
await Workspace.create()
}
})
return ctx.subject("account", accountID, { accountID, email })
},
}).fetch(request, env, ctx)
+3 -316
View File
@@ -1,39 +1,18 @@
import { z } from "zod"
import { Hono, MiddlewareHandler } from "hono"
import { cors } from "hono/cors"
import { HTTPException } from "hono/http-exception"
import { zValidator } from "@hono/zod-validator"
import { Resource } from "sst"
import { type ProviderMetadata, type LanguageModelUsage, generateText, streamText } from "ai"
import { type ProviderMetadata, type LanguageModelUsage } from "ai"
import { createAnthropic } from "@ai-sdk/anthropic"
import { createOpenAI } from "@ai-sdk/openai"
import { createOpenAICompatible } from "@ai-sdk/openai-compatible"
import type { LanguageModelV2Prompt } from "@ai-sdk/provider"
import { type ChatCompletionCreateParamsBase } from "openai/resources/chat/completions"
import { Actor } from "@opencode/cloud-core/actor.js"
import { and, Database, eq, sql } from "@opencode/cloud-core/drizzle/index.js"
import { UserTable } from "@opencode/cloud-core/schema/user.sql.js"
import { Database, eq, sql } from "@opencode/cloud-core/drizzle/index.js"
import { KeyTable } from "@opencode/cloud-core/schema/key.sql.js"
import { createClient } from "@openauthjs/openauth/client"
import { Log } from "@opencode/cloud-core/util/log.js"
import { Billing } from "@opencode/cloud-core/billing.js"
import { Workspace } from "@opencode/cloud-core/workspace.js"
import { BillingTable, PaymentTable, UsageTable } from "@opencode/cloud-core/schema/billing.sql.js"
import { centsToMicroCents } from "@opencode/cloud-core/util/price.js"
import { Identifier } from "../../core/src/identifier"
import { Resource } from "@opencode/cloud-resource"
type Env = {}
let _client: ReturnType<typeof createClient>
const client = () => {
if (_client) return _client
_client = createClient({
clientID: "api",
issuer: Resource.AUTH_API_URL.value,
})
return _client
}
const SUPPORTED_MODELS = {
"anthropic/claude-sonnet-4": {
input: 0.0000015,
@@ -72,10 +51,6 @@ const SUPPORTED_MODELS = {
},
}
const log = Log.create({
namespace: "api",
})
const GatewayAuth: MiddlewareHandler = async (c, next) => {
const authHeader = c.req.header("authorization")
@@ -125,56 +100,6 @@ const GatewayAuth: MiddlewareHandler = async (c, next) => {
await next()
}
const RestAuth: MiddlewareHandler = async (c, next) => {
const authorization = c.req.header("authorization")
if (!authorization) {
return Actor.provide("public", {}, next)
}
const token = authorization.split(" ")[1]
if (!token)
throw new HTTPException(403, {
message: "Bearer token is required.",
})
const verified = await client().verify(token)
if (verified.err) {
throw new HTTPException(403, {
message: "Invalid token.",
})
}
let subject = verified.subject as Actor.Info
if (subject.type === "account") {
const workspaceID = c.req.header("x-opencode-workspace")
const email = subject.properties.email
if (workspaceID) {
const user = await Database.use((tx) =>
tx
.select({
id: UserTable.id,
workspaceID: UserTable.workspaceID,
email: UserTable.email,
})
.from(UserTable)
.where(and(eq(UserTable.email, email), eq(UserTable.workspaceID, workspaceID)))
.then((rows) => rows[0]),
)
if (!user)
throw new HTTPException(403, {
message: "You do not have access to this workspace.",
})
subject = {
type: "user",
properties: {
userID: user.id,
workspaceID: workspaceID,
email: user.email,
},
}
}
}
await Actor.provide(subject.type, subject.properties, next)
}
const app = new Hono<{ Bindings: Env; Variables: { keyRecord?: { id: string; workspaceID: string } } }>()
.get("/", (c) => c.text("Hello, world!"))
.post("/v1/chat/completions", GatewayAuth, async (c) => {
@@ -664,244 +589,6 @@ const app = new Hono<{ Bindings: Env; Variables: { keyRecord?: { id: string; wor
}
})
})
.use("/*", cors())
.use(RestAuth)
.get("/rest/account", async (c) => {
const account = Actor.assert("account")
let workspaces = await Workspace.list()
if (workspaces.length === 0) {
await Workspace.create()
workspaces = await Workspace.list()
}
return c.json({
id: account.properties.accountID,
email: account.properties.email,
workspaces,
})
})
.get("/billing/info", async (c) => {
const billing = await Billing.get()
const payments = await Database.use((tx) =>
tx
.select()
.from(PaymentTable)
.where(eq(PaymentTable.workspaceID, Actor.workspace()))
.orderBy(sql`${PaymentTable.timeCreated} DESC`)
.limit(100),
)
const usage = await Database.use((tx) =>
tx
.select()
.from(UsageTable)
.where(eq(UsageTable.workspaceID, Actor.workspace()))
.orderBy(sql`${UsageTable.timeCreated} DESC`)
.limit(100),
)
return c.json({ billing, payments, usage })
})
.post(
"/billing/checkout",
zValidator(
"json",
z.custom<{
success_url: string
cancel_url: string
}>(),
),
async (c) => {
const account = Actor.assert("user")
const body = await c.req.json()
const customer = await Billing.get()
const session = await Billing.stripe().checkout.sessions.create({
mode: "payment",
line_items: [
{
price_data: {
currency: "usd",
product_data: {
name: "opencode credits",
},
unit_amount: 2000, // $20 minimum
},
quantity: 1,
},
],
payment_intent_data: {
setup_future_usage: "on_session",
},
...(customer.customerID
? { customer: customer.customerID }
: {
customer_email: account.properties.email,
customer_creation: "always",
}),
metadata: {
workspaceID: Actor.workspace(),
},
currency: "usd",
payment_method_types: ["card"],
success_url: body.success_url,
cancel_url: body.cancel_url,
})
return c.json({
url: session.url,
})
},
)
.post("/billing/portal", async (c) => {
const body = await c.req.json()
const customer = await Billing.get()
if (!customer?.customerID) {
throw new Error("No stripe customer ID")
}
const session = await Billing.stripe().billingPortal.sessions.create({
customer: customer.customerID,
return_url: body.return_url,
})
return c.json({
url: session.url,
})
})
.post("/stripe/webhook", async (c) => {
const body = await Billing.stripe().webhooks.constructEventAsync(
await c.req.text(),
c.req.header("stripe-signature")!,
Resource.STRIPE_WEBHOOK_SECRET.value,
)
console.log(body.type, JSON.stringify(body, null, 2))
if (body.type === "checkout.session.completed") {
const workspaceID = body.data.object.metadata?.workspaceID
const customerID = body.data.object.customer as string
const paymentID = body.data.object.payment_intent as string
const amount = body.data.object.amount_total
if (!workspaceID) throw new Error("Workspace ID not found")
if (!customerID) throw new Error("Customer ID not found")
if (!amount) throw new Error("Amount not found")
if (!paymentID) throw new Error("Payment ID not found")
await Actor.provide("system", { workspaceID }, async () => {
const customer = await Billing.get()
if (customer?.customerID && customer.customerID !== customerID) throw new Error("Customer ID mismatch")
// set customer metadata
if (!customer?.customerID) {
await Billing.stripe().customers.update(customerID, {
metadata: {
workspaceID,
},
})
}
// get payment method for the payment intent
const paymentIntent = await Billing.stripe().paymentIntents.retrieve(paymentID, {
expand: ["payment_method"],
})
const paymentMethod = paymentIntent.payment_method
if (!paymentMethod || typeof paymentMethod === "string") throw new Error("Payment method not expanded")
await Database.transaction(async (tx) => {
await tx
.update(BillingTable)
.set({
balance: sql`${BillingTable.balance} + ${centsToMicroCents(amount)}`,
customerID,
paymentMethodID: paymentMethod.id,
paymentMethodLast4: paymentMethod.card!.last4,
})
.where(eq(BillingTable.workspaceID, workspaceID))
await tx.insert(PaymentTable).values({
workspaceID,
id: Identifier.create("payment"),
amount: centsToMicroCents(amount),
paymentID,
customerID,
})
})
})
}
console.log("finished handling")
return c.json("ok", 200)
})
.get("/keys", async (c) => {
const user = Actor.assert("user")
const keys = await Database.use((tx) =>
tx
.select({
id: KeyTable.id,
name: KeyTable.name,
key: KeyTable.key,
userID: KeyTable.userID,
timeCreated: KeyTable.timeCreated,
timeUsed: KeyTable.timeUsed,
})
.from(KeyTable)
.where(eq(KeyTable.workspaceID, user.properties.workspaceID))
.orderBy(sql`${KeyTable.timeCreated} DESC`),
)
return c.json({ keys })
})
.post("/keys", zValidator("json", z.object({ name: z.string().min(1).max(255) })), async (c) => {
const user = Actor.assert("user")
const { name } = c.req.valid("json")
// Generate secret key: sk- + 64 random characters (upper, lower, numbers)
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
let randomPart = ""
for (let i = 0; i < 64; i++) {
randomPart += chars.charAt(Math.floor(Math.random() * chars.length))
}
const secretKey = `sk-${randomPart}`
const keyRecord = await Database.use((tx) =>
tx
.insert(KeyTable)
.values({
id: Identifier.create("key"),
workspaceID: user.properties.workspaceID,
userID: user.properties.userID,
name,
key: secretKey,
timeUsed: null,
})
.returning(),
)
return c.json({
key: secretKey,
id: keyRecord[0].id,
name: keyRecord[0].name,
created: keyRecord[0].timeCreated,
})
})
.delete("/keys/:id", async (c) => {
const user = Actor.assert("user")
const keyId = c.req.param("id")
const result = await Database.use((tx) =>
tx
.delete(KeyTable)
.where(and(eq(KeyTable.id, keyId), eq(KeyTable.workspaceID, user.properties.workspaceID)))
.returning({ id: KeyTable.id }),
)
if (result.length === 0) {
return c.json({ error: "Key not found" }, 404)
}
return c.json({ success: true, id: result[0].id })
})
.all("*", (c) => c.text("Not Found"))
export type ApiType = typeof app
+1 -1
View File
@@ -15,7 +15,7 @@ declare module "sst" {
"value": string
}
"Console": {
"type": "sst.cloudflare.StaticSite"
"type": "sst.cloudflare.SolidStart"
"url": string
}
"DATABASE_PASSWORD": {
+13
View File
@@ -0,0 +1,13 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"dependencies": {
"@cloudflare/workers-types": "^4.20250830.0",
},
},
},
"packages": {
"@cloudflare/workers-types": ["@cloudflare/workers-types@4.20250830.0", "", {}, "sha512-uAGZFqEBFnCiwIokxMnrrtjIkT8qyGT1LACSScEUyW7nKmtD0Viykp9QZWrIlssyEp/MDB6XsdALF8y6upxpcg=="],
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/cloud-resource",
"dependencies": {
"@cloudflare/workers-types": "^4.20250830.0"
},
"exports": {
".": {
"production": {
"import": "./resource.cloudflare.ts"
},
"import": "./resource.node.ts"
}
}
}
+15
View File
@@ -0,0 +1,15 @@
import { env } from "cloudflare:workers"
export const Resource = new Proxy(
{},
{
get(_target, prop: string) {
if (prop in env) {
// @ts-expect-error
const value = env[prop]
return typeof value === "string" ? JSON.parse(value) : value
}
throw new Error(`"${prop}" is not linked in your sst.config.ts (cloudflare)`)
},
},
) as Record<string, any>
+1
View File
@@ -0,0 +1 @@
export { Resource } from "sst"
+92
View File
@@ -0,0 +1,92 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
import "sst"
declare module "sst" {
export interface Resource {
"ANTHROPIC_API_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"AUTH_API_URL": {
"type": "sst.sst.Linkable"
"value": string
}
"Console": {
"type": "sst.cloudflare.SolidStart"
"url": string
}
"DATABASE_PASSWORD": {
"type": "sst.sst.Secret"
"value": string
}
"DATABASE_USERNAME": {
"type": "sst.sst.Secret"
"value": string
}
"Database": {
"database": string
"host": string
"password": string
"port": number
"type": "sst.sst.Linkable"
"username": string
}
"GITHUB_APP_ID": {
"type": "sst.sst.Secret"
"value": string
}
"GITHUB_APP_PRIVATE_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"GITHUB_CLIENT_ID_CONSOLE": {
"type": "sst.sst.Secret"
"value": string
}
"GITHUB_CLIENT_SECRET_CONSOLE": {
"type": "sst.sst.Secret"
"value": string
}
"GOOGLE_CLIENT_ID": {
"type": "sst.sst.Secret"
"value": string
}
"OPENAI_API_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_SECRET_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_WEBHOOK_SECRET": {
"type": "sst.sst.Linkable"
"value": string
}
"Web": {
"type": "sst.cloudflare.Astro"
"url": string
}
"ZHIPU_API_KEY": {
"type": "sst.sst.Secret"
"value": string
}
}
}
// cloudflare
import * as cloudflare from "@cloudflare/workers-types";
declare module "sst" {
export interface Resource {
"Api": cloudflare.Service
"AuthApi": cloudflare.Service
"AuthStorage": cloudflare.KVNamespace
"Bucket": cloudflare.R2Bucket
"GatewayApi": cloudflare.Service
}
}
import "sst"
export {}
@@ -2,11 +2,11 @@
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/node22/tsconfig.json",
"compilerOptions": {
"jsx": "preserve",
"jsxImportSource": "solid-js",
"module": "esnext",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"types": ["vite/client"]
"types": [
"@cloudflare/workers-types",
"node"
]
}
}
-2
View File
@@ -1,2 +0,0 @@
node_modules
dist
-38
View File
@@ -1,38 +0,0 @@
<!doctype html>
<html lang="en" data-color-mode="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>OpenControl</title>
<link rel="shortcut icon" type="image/ico" href="/favicon.ico" />
<link rel="icon" href="/favicon.ico" sizes="48x48">
<link rel="icon" href="/favicon.svg" media="(prefers-color-scheme: light)">
<link rel="icon" href="/favicon-dark.svg" media="(prefers-color-scheme: dark)">
<link rel="shortcut icon" href="/favicon.svg" type="image/svg+xml">
<meta property="twitter:image" content="%BASE_URL%/social-share.png">
<meta property="og:title" content="OpenControl">
<meta property="og:url" content="%BASE_URL%">
<meta property="og:locale" content="en">
<meta property="og:description" content="Control your infrastructure with AI.">
<meta property="og:site_name" content="OpenControl">
<meta name="twitter:card" content="summary_large_image">
<meta name="description" content="Control your infrastructure with AI.">
<meta property="og:image" content="%BASE_URL%/social-share.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&family=Rubik:wght@300..900&display=swap" rel="stylesheet">
<!--ssr-head-->
<!--ssr-assets-->
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root">
<!--ssr-outlet-->
</div>
<script type="module" src="/src/entry-client.tsx"></script>
</body>
</html>
-29
View File
@@ -1,29 +0,0 @@
0 info it worked if it ends with ok
1 verbose cli [
1 verbose cli '/usr/local/bin/node',
1 verbose cli '/Users/frank/Sites/opencode/node_modules/.bin/npm',
1 verbose cli 'run',
1 verbose cli 'dev'
1 verbose cli ]
2 info using npm@2.15.12
3 info using node@v20.18.1
4 verbose stack Error: Invalid name: "@opencode/cloud/web"
4 verbose stack at ensureValidName (/Users/frank/Sites/opencode/node_modules/npm/node_modules/normalize-package-data/lib/fixer.js:336:15)
4 verbose stack at Object.fixNameField (/Users/frank/Sites/opencode/node_modules/npm/node_modules/normalize-package-data/lib/fixer.js:215:5)
4 verbose stack at /Users/frank/Sites/opencode/node_modules/npm/node_modules/normalize-package-data/lib/normalize.js:32:38
4 verbose stack at Array.forEach (<anonymous>)
4 verbose stack at normalize (/Users/frank/Sites/opencode/node_modules/npm/node_modules/normalize-package-data/lib/normalize.js:31:15)
4 verbose stack at final (/Users/frank/Sites/opencode/node_modules/npm/node_modules/read-package-json/read-json.js:349:5)
4 verbose stack at then (/Users/frank/Sites/opencode/node_modules/npm/node_modules/read-package-json/read-json.js:124:5)
4 verbose stack at ReadFileContext.<anonymous> (/Users/frank/Sites/opencode/node_modules/npm/node_modules/read-package-json/read-json.js:295:20)
4 verbose stack at ReadFileContext.callback (/Users/frank/Sites/opencode/node_modules/npm/node_modules/graceful-fs/graceful-fs.js:78:16)
4 verbose stack at FSReqCallback.readFileAfterOpen [as oncomplete] (node:fs:299:13)
5 verbose cwd /Users/frank/Sites/opencode/cloud/web
6 error Darwin 24.5.0
7 error argv "/usr/local/bin/node" "/Users/frank/Sites/opencode/node_modules/.bin/npm" "run" "dev"
8 error node v20.18.1
9 error npm v2.15.12
10 error Invalid name: "@opencode/cloud/web"
11 error If you need help, you may report this error at:
11 error <https://github.com/npm/npm/issues>
12 verbose exit [ 1, true ]
-32
View File
@@ -1,32 +0,0 @@
{
"name": "@opencode/cloud-web",
"version": "0.5.2",
"private": true,
"description": "",
"type": "module",
"scripts": {
"start": "vite",
"dev": "vite",
"build": "bun build:server && bun build:client",
"build:client": "vite build --outDir dist/client",
"build:server": "vite build --ssr src/entry-server.tsx --outDir dist/server",
"serve": "vite preview",
"sst:dev": "bun sst shell --target Console -- bun dev"
},
"license": "MIT",
"devDependencies": {
"typescript": "catalog:",
"vite": "6.2.2",
"vite-plugin-pages": "0.32.5",
"vite-plugin-solid": "2.11.6"
},
"dependencies": {
"@kobalte/core": "0.13.9",
"@openauthjs/solid": "0.0.0-20250322224806",
"@solid-primitives/storage": "4.3.1",
"@solidjs/meta": "0.29.4",
"@solidjs/router": "0.15.3",
"solid-js": "1.9.5",
"solid-list": "0.3.0"
}
}
-3
View File
@@ -1,3 +0,0 @@
<svg width="28" height="32" viewBox="0 0 28 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 31.5L0 23.6873V7.81266L14 0L28 7.81266V23.6873L14 31.5ZM14 28.4664L25.3456 22.0251V9.47493L14 2.99209L2.65443 9.47493V22.0251L14 28.4664ZM13.9572 24.6016C12.2732 24.6016 10.7176 24.1999 9.29052 23.3964C7.89195 22.593 6.7788 21.5125 5.95107 20.155C5.12334 18.7698 4.70948 17.2599 4.70948 15.6253C4.70948 13.9908 5.12334 12.4947 5.95107 11.1372C6.7788 9.77968 7.89195 8.69921 9.29052 7.89578C10.7176 7.06464 12.2732 6.64908 13.9572 6.64908C15.6412 6.64908 17.1825 7.06464 18.581 7.89578C19.9796 8.69921 21.0928 9.77968 21.9205 11.1372C22.7768 12.4947 23.2049 13.9908 23.2049 15.6253C23.2049 17.2599 22.791 18.7559 21.9633 20.1135C21.1356 21.471 20.0224 22.5653 18.6239 23.3964C17.2253 24.1999 15.6697 24.6016 13.9572 24.6016ZM13.9572 22.2744C15.213 22.2744 16.3547 21.9697 17.3823 21.3602C18.4098 20.7507 19.2375 19.9472 19.8654 18.9499C20.4934 17.9248 20.8073 16.8166 20.8073 15.6253C20.8073 14.4063 20.4934 13.2982 19.8654 12.3008C19.2375 11.3034 18.4098 10.5 17.3823 9.8905C16.3547 9.281 15.213 8.97625 13.9572 8.97625C12.7299 8.97625 11.5882 9.281 10.5321 9.8905C9.50459 10.5 8.67686 11.3034 8.04893 12.3008C7.421 13.2982 7.10703 14.4063 7.10703 15.6253C7.10703 16.8166 7.421 17.9248 8.04893 18.9499C8.67686 19.9472 9.50459 20.7507 10.5321 21.3602C11.5882 21.9697 12.7299 22.2744 13.9572 22.2744Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

-3
View File
@@ -1,3 +0,0 @@
<svg width="28" height="32" viewBox="0 0 28 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 31.5L0 23.6873V7.81266L14 0L28 7.81266V23.6873L14 31.5ZM14 28.4664L25.3456 22.0251V9.47493L14 2.99209L2.65443 9.47493V22.0251L14 28.4664ZM13.9572 24.6016C12.2732 24.6016 10.7176 24.1999 9.29052 23.3964C7.89195 22.593 6.7788 21.5125 5.95107 20.155C5.12334 18.7698 4.70948 17.2599 4.70948 15.6253C4.70948 13.9908 5.12334 12.4947 5.95107 11.1372C6.7788 9.77968 7.89195 8.69921 9.29052 7.89578C10.7176 7.06464 12.2732 6.64908 13.9572 6.64908C15.6412 6.64908 17.1825 7.06464 18.581 7.89578C19.9796 8.69921 21.0928 9.77968 21.9205 11.1372C22.7768 12.4947 23.2049 13.9908 23.2049 15.6253C23.2049 17.2599 22.791 18.7559 21.9633 20.1135C21.1356 21.471 20.0224 22.5653 18.6239 23.3964C17.2253 24.1999 15.6697 24.6016 13.9572 24.6016ZM13.9572 22.2744C15.213 22.2744 16.3547 21.9697 17.3823 21.3602C18.4098 20.7507 19.2375 19.9472 19.8654 18.9499C20.4934 17.9248 20.8073 16.8166 20.8073 15.6253C20.8073 14.4063 20.4934 13.2982 19.8654 12.3008C19.2375 11.3034 18.4098 10.5 17.3823 9.8905C16.3547 9.281 15.213 8.97625 13.9572 8.97625C12.7299 8.97625 11.5882 9.281 10.5321 9.8905C9.50459 10.5 8.67686 11.3034 8.04893 12.3008C7.421 13.2982 7.10703 14.4063 7.10703 15.6253C7.10703 16.8166 7.421 17.9248 8.04893 18.9499C8.67686 19.9472 9.50459 20.7507 10.5321 21.3602C11.5882 21.9697 12.7299 22.2744 13.9572 22.2744Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

-24
View File
@@ -1,24 +0,0 @@
import fs from "fs"
import path from "path"
import { generateHydrationScript, getAssets } from "solid-js/web"
const dist = import.meta.resolve("../dist").replace("file://", "")
const serverEntry = await import("../dist/server/entry-server.js")
const template = fs.readFileSync(path.join(dist, "client/index.html"), "utf-8")
fs.writeFileSync(path.join(dist, "client/fallback.html"), template)
const routes = ["/", "/foo"]
for (const route of routes) {
const { app } = serverEntry.render({ url: route })
const html = template
.replace("<!--ssr-outlet-->", app)
.replace("<!--ssr-head-->", generateHydrationScript())
.replace("<!--ssr-assets-->", getAssets())
const filePath = dist + `/client${route === "/" ? "/index" : route}.html`
fs.mkdirSync(path.dirname(filePath), {
recursive: true,
})
fs.writeFileSync(filePath, html)
console.log(`Pre-rendered: ${filePath}`)
}
-42
View File
@@ -1,42 +0,0 @@
/// <reference types="vite-plugin-pages/client-solid" />
import { Router } from "@solidjs/router"
import routes from "~solid-pages"
import "./ui/style/index.css"
import { MetaProvider } from "@solidjs/meta"
import { AccountProvider } from "./components/context-account"
import { DialogProvider } from "./ui/context-dialog"
import { DialogString } from "./ui/dialog-string"
import { DialogSelect } from "./ui/dialog-select"
import { ThemeProvider } from "./components/context-theme"
import { Suspense } from "solid-js"
import { OpenAuthProvider } from "./components/context-openauth"
export function App(props: { url?: string }) {
return (
<ThemeProvider>
<Suspense>
<DialogProvider>
<DialogString />
<DialogSelect />
<OpenAuthProvider
clientID="web"
issuer={import.meta.env.VITE_AUTH_URL || "http://dummy"}
>
<AccountProvider>
<MetaProvider>
<Router
children={routes}
url={props.url}
root={(props) => {
return <>{props.children}</>
}}
/>
</MetaProvider>
</AccountProvider>
</OpenAuthProvider>
</DialogProvider>
</Suspense>
</ThemeProvider>
)
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 439 KiB

@@ -1,99 +0,0 @@
import { createContext, createEffect, ParentProps, Suspense, useContext } from "solid-js"
import { makePersisted } from "@solid-primitives/storage"
import { createStore } from "solid-js/store"
import { useOpenAuth } from "./context-openauth"
import { createAsync } from "@solidjs/router"
import { isServer } from "solid-js/web"
type Storage = {
accounts: Record<
string,
{
id: string
email: string
workspaces: {
id: string
name: string
slug: string
}[]
}
>
}
const context = createContext<ReturnType<typeof init>>()
function init() {
const auth = useOpenAuth()
const [store, setStore] = makePersisted(
createStore<Storage>({
accounts: {},
}),
{
name: "opencontrol.account",
},
)
async function refresh(id: string) {
return fetch(import.meta.env.VITE_API_URL + "/rest/account", {
headers: {
authorization: `Bearer ${await auth.access(id)}`,
},
})
.then((val) => val.json())
.then((val) => setStore("accounts", id, val as any))
}
createEffect((previous: string[]) => {
if (Object.keys(auth.all).length === 0) {
return []
}
for (const item of Object.values(auth.all)) {
if (previous.includes(item.id)) continue
refresh(item.id)
}
return Object.keys(auth.all)
}, [] as string[])
const result = {
get all() {
return Object.keys(auth.all)
.map((id) => store.accounts[id])
.filter(Boolean)
},
get current() {
if (!auth.subject) return undefined
return store.accounts[auth.subject.id]
},
refresh,
get ready() {
return Object.keys(auth.all).length === result.all.length
},
}
return result
}
export function AccountProvider(props: ParentProps) {
const ctx = init()
const resource = createAsync(async () => {
await new Promise<void>((resolve) => {
if (isServer) return resolve()
createEffect(() => {
if (ctx.ready) resolve()
})
})
return null
})
return (
<Suspense>
{resource()}
<context.Provider value={ctx}>{props.children}</context.Provider>
</Suspense>
)
}
export function useAccount() {
const result = useContext(context)
if (!result) throw new Error("no account context")
return result
}
@@ -1,180 +0,0 @@
import { createClient } from "@openauthjs/openauth/client"
import { makePersisted } from "@solid-primitives/storage"
import { createAsync } from "@solidjs/router"
import {
batch,
createContext,
createEffect,
createResource,
createSignal,
onMount,
ParentProps,
Show,
Suspense,
useContext,
} from "solid-js"
import { createStore, produce } from "solid-js/store"
import { isServer } from "solid-js/web"
interface Storage {
subjects: Record<string, SubjectInfo>
current?: string
}
interface Context {
all: Record<string, SubjectInfo>
subject?: SubjectInfo
switch(id: string): void
logout(id: string): void
access(id?: string): Promise<string | undefined>
authorize(opts?: AuthorizeOptions): void
}
export interface AuthorizeOptions {
redirectPath?: string
provider?: string
}
interface SubjectInfo {
id: string
refresh: string
}
interface AuthContextOpts {
issuer: string
clientID: string
}
const context = createContext<Context>()
export function OpenAuthProvider(props: ParentProps<AuthContextOpts>) {
const client = createClient({
issuer: props.issuer,
clientID: props.clientID,
})
const [storage, setStorage] = makePersisted(
createStore<Storage>({
subjects: {},
}),
{
name: `${props.issuer}.auth`,
},
)
const resource = createAsync(async () => {
if (isServer) return true
const hash = new URLSearchParams(window.location.search.substring(1))
const code = hash.get("code")
const state = hash.get("state")
if (code && state) {
const oldState = sessionStorage.getItem("openauth.state")
const verifier = sessionStorage.getItem("openauth.verifier")
const redirect = sessionStorage.getItem("openauth.redirect")
if (redirect && verifier && oldState === state) {
const result = await client.exchange(code, redirect, verifier)
if (!result.err) {
const id = result.tokens.refresh.split(":").slice(0, -1).join(":")
batch(() => {
setStorage("subjects", id, {
id: id,
refresh: result.tokens.refresh,
})
setStorage("current", id)
})
}
}
}
return true
})
async function authorize(opts?: AuthorizeOptions) {
const redirect = new URL(window.location.origin + (opts?.redirectPath ?? "/")).toString()
const authorize = await client.authorize(redirect, "code", {
pkce: true,
provider: opts?.provider,
})
sessionStorage.setItem("openauth.state", authorize.challenge.state)
sessionStorage.setItem("openauth.redirect", redirect)
if (authorize.challenge.verifier) sessionStorage.setItem("openauth.verifier", authorize.challenge.verifier)
window.location.href = authorize.url
}
const accessCache = new Map<string, string>()
const pendingRequests = new Map<string, Promise<any>>()
async function access(id: string) {
const pending = pendingRequests.get(id)
if (pending) return pending
const promise = (async () => {
const existing = accessCache.get(id)
const subject = storage.subjects[id]
const access = await client.refresh(subject.refresh, {
access: existing,
})
if (access.err) {
pendingRequests.delete(id)
ctx.logout(id)
return
}
if (access.tokens) {
setStorage("subjects", id, "refresh", access.tokens.refresh)
accessCache.set(id, access.tokens.access)
}
pendingRequests.delete(id)
return access.tokens?.access || existing!
})()
pendingRequests.set(id, promise)
return promise
}
const ctx: Context = {
get all() {
return storage.subjects
},
get subject() {
if (!storage.current) return
return storage.subjects[storage.current!]
},
switch(id: string) {
if (!storage.subjects[id]) return
setStorage("current", id)
},
authorize,
logout(id: string) {
if (!storage.subjects[id]) return
setStorage(
produce((s) => {
delete s.subjects[id]
if (s.current === id) s.current = Object.keys(s.subjects)[0]
}),
)
},
async access(id?: string) {
id = id || storage.current
if (!id) return
return access(id || storage.current!)
},
}
createEffect(() => {
if (!resource()) return
if (storage.current) return
const [first] = Object.keys(storage.subjects)
if (first) {
setStorage("current", first)
return
}
})
return (
<>
{resource()}
<context.Provider value={ctx}>{props.children}</context.Provider>
</>
)
}
export function useOpenAuth() {
const result = useContext(context)
if (!result) throw new Error("no auth context")
return result
}
@@ -1,39 +0,0 @@
import { createStore } from "solid-js/store"
import { makePersisted } from "@solid-primitives/storage"
import { createEffect } from "solid-js"
import { createInitializedContext } from "../util/context"
import { isServer } from "solid-js/web"
interface Storage {
mode: "light" | "dark"
}
export const { provider: ThemeProvider, use: useTheme } =
createInitializedContext("ThemeContext", () => {
const [store, setStore] = makePersisted(
createStore<Storage>({
mode:
!isServer &&
window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light",
}),
{
name: "theme",
},
)
createEffect(() => {
document.documentElement.setAttribute("data-color-mode", store.mode)
})
return {
setMode(mode: Storage["mode"]) {
setStore("mode", mode)
},
get mode() {
return store.mode
},
ready: true,
}
})
-13
View File
@@ -1,13 +0,0 @@
/* @refresh reload */
import { hydrate, render } from "solid-js/web"
import { App } from "./app"
if (import.meta.env.DEV) {
render(() => <App />, document.getElementById("root")!)
}
if (!import.meta.env.DEV) {
if ("_$HY" in window) hydrate(() => <App />, document.getElementById("root")!)
else render(() => <App />, document.getElementById("root")!)
}
-7
View File
@@ -1,7 +0,0 @@
import { renderToStringAsync } from "solid-js/web"
import { App } from "./app"
export async function render(props: { url: string }) {
const app = await renderToStringAsync(() => <App url={props.url} />)
return { app }
}
-11
View File
@@ -1,11 +0,0 @@
import { WorkspaceProvider } from "./components/context-workspace"
import { ParentProps } from "solid-js"
import Layout from "./components/layout"
export default function Index(props: ParentProps) {
return (
<WorkspaceProvider>
<Layout>{props.children}</Layout>
</WorkspaceProvider>
)
}
@@ -1,56 +0,0 @@
.root {
display: flex;
flex-direction: column;
gap: var(--space-4);
padding: var(--space-7) var(--space-5) var(--space-5);
[data-slot="billing-info"] {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
[data-slot="header"] {
display: flex;
flex-direction: column;
gap: var(--space-1-5);
h2 {
text-transform: uppercase;
font-weight: 600;
letter-spacing: -0.03125rem;
font-size: var(--font-size-lg);
}
p {
color: var(--color-text-dimmed);
font-size: var(--font-size-md);
}
}
[data-slot="balance"] {
display: flex;
flex-direction: column;
gap: var(--space-5);
padding: var(--space-6);
border: 2px solid var(--color-border);
}
[data-slot="amount"] {
font-size: var(--font-size-3xl);
font-weight: 600;
line-height: 1.2;
}
@media (min-width: 40rem) {
[data-slot="balance"] {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
[data-slot="amount"] {
margin: 0;
}
}
}
-132
View File
@@ -1,132 +0,0 @@
import { Button } from "../../ui/button"
import { useApi } from "../components/context-api"
import { createEffect, createSignal, createResource, For } from "solid-js"
import { useWorkspace } from "../components/context-workspace"
import style from "./billing.module.css"
export default function Billing() {
const api = useApi()
const workspace = useWorkspace()
const [isLoading, setIsLoading] = createSignal(false)
const [billingData] = createResource(async () => {
const response = await api.billing.info.$get()
return response.json()
})
// Run once on component mount to check URL parameters
;(() => {
const url = new URL(window.location.href)
const result = url.hash
console.log("STRIPE RESULT", result)
if (url.hash === "#success") {
setIsLoading(true)
// Remove the hash from the URL
window.history.replaceState(null, "", window.location.pathname + window.location.search)
}
})()
createEffect((old?: number) => {
if (old && old !== billingData()?.billing?.balance) {
setIsLoading(false)
}
return billingData()?.billing?.balance
})
const handleBuyCredits = async () => {
try {
setIsLoading(true)
const baseUrl = window.location.href
const successUrl = new URL(baseUrl)
successUrl.hash = "success"
const response = await api.billing.checkout
.$post({
json: {
success_url: successUrl.toString(),
cancel_url: baseUrl,
},
})
.then((r) => r.json() as any)
window.location.href = response.url
} catch (error) {
console.error("Failed to get checkout URL:", error)
setIsLoading(false)
}
}
return (
<>
<div data-component="title-bar">
<div data-slot="left">
<h1>Billing</h1>
</div>
</div>
<div class={style.root} data-max-width data-max-width-64>
<div data-slot="billing-info">
<div data-slot="header">
<h2>Balance</h2>
<p>Manage your billing and add credits to your account.</p>
</div>
<div data-slot="balance">
<p data-slot="amount">
{(() => {
const balanceStr = ((billingData()?.billing?.balance ?? 0) / 100000000).toFixed(2)
return `$${balanceStr === "-0.00" ? "0.00" : balanceStr}`
})()}
</p>
<Button color="primary" disabled={isLoading()} onClick={handleBuyCredits}>
{isLoading() ? "Loading..." : "Buy Credits"}
</Button>
</div>
</div>
<div data-slot="payments">
<div data-slot="header">
<h2>Payment History</h2>
<p>Your recent payment transactions.</p>
</div>
<div data-slot="payment-list">
<For each={billingData()?.payments} fallback={<p>No payments found.</p>}>
{(payment) => (
<div data-slot="payment-item">
<span data-slot="payment-id">{payment.id}</span>
{" | "}
<span data-slot="payment-amount">${((payment.amount ?? 0) / 100000000).toFixed(2)}</span>
{" | "}
<span data-slot="payment-date">{new Date(payment.timeCreated).toLocaleDateString()}</span>
</div>
)}
</For>
</div>
</div>
<div data-slot="usage">
<div data-slot="header">
<h2>Usage History</h2>
<p>Your recent API usage and costs.</p>
</div>
<div data-slot="usage-list">
<For each={billingData()?.usage} fallback={<p>No usage found.</p>}>
{(usage) => (
<div data-slot="usage-item">
<span data-slot="usage-model">{usage.model}</span>
{" | "}
<span data-slot="usage-tokens">{usage.inputTokens + usage.outputTokens} tokens</span>
{" | "}
<span data-slot="usage-cost">${((usage.cost ?? 0) / 100000000).toFixed(4)}</span>
{" | "}
<span data-slot="usage-date">{new Date(usage.timeCreated).toLocaleDateString()}</span>
</div>
)}
</For>
</div>
</div>
</div>
</>
)
}
@@ -1,11 +0,0 @@
You are OpenControl, an interactive CLI tool that helps users execute various tasks.
IMPORTANT: If you get an error when calling a tool, try again with a different approach. Be creative, do not give up, try different inputs to the tool. You should chain together multiple tool calls. ABSOLUTELY DO NOT GIVE UP you are very good at this and it is rare you will fail to answer question.
You should be concise, direct, and to the point.
IMPORTANT: You should NOT answer with unnecessary preamble or postamble (such as explaining your code or summarizing your action), unless the user asks you to.
IMPORTANT: You should minimize output tokens as much as possible while maintaining helpfulness, quality, and accuracy. Only address the specific query or task at hand, avoiding tangential information unless absolutely critical for completing the request. If you can answer in 1-3 sentences or a short paragraph, please do.
IMPORTANT: You should NOT answer with unnecessary preamble or postamble (such as explaining your code or summarizing your action), unless the user asks you to.
IMPORTANT: Keep your responses short, since they will be displayed on a command line interface. You MUST answer concisely with fewer than 4 lines (not including tool use or code generation), unless user asks for detail. Answer the user's question directly, without elaboration, explanation, or details. One word answers are best. Avoid introductions, conclusions, and explanations. You MUST avoid text before/after your response, such as "The answer is <answer>.", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...".
@@ -1,271 +0,0 @@
import { createResource } from "solid-js"
import { createStore, produce } from "solid-js/store"
import SYSTEM_PROMPT from "./system.txt?raw"
import type {
LanguageModelV1Prompt,
LanguageModelV1CallOptions,
LanguageModelV1,
} from "ai"
interface Tool {
name: string
description: string
inputSchema: any
}
interface ToolCallerProps {
tool: {
list: () => Promise<Tool[]>
call: (input: { name: string; arguments: any }) => Promise<any>
}
generate: (
prompt: LanguageModelV1CallOptions,
) => Promise<
| { err: "rate" }
| { err: "context" }
| { err: "balance" }
| ({ err: false } & Awaited<ReturnType<LanguageModelV1["doGenerate"]>>)
>
onPromptUpdated?: (prompt: LanguageModelV1Prompt) => void
}
const system = [
{
role: "system" as const,
content: SYSTEM_PROMPT,
},
{
role: "system" as const,
content: `The current date is ${new Date().toDateString()}. Always use this current date when responding to relative date queries.`,
},
]
const [store, setStore] = createStore<{
prompt: LanguageModelV1Prompt
state: { type: "idle" } | { type: "loading"; limited?: boolean }
}>({
prompt: [...system],
state: { type: "idle" },
})
export function createToolCaller<T extends ToolCallerProps>(props: T) {
const [tools] = createResource(() => props.tool.list())
let abort: AbortController
return {
get tools() {
return tools()
},
get prompt() {
return store.prompt
},
get state() {
return store.state
},
clear() {
setStore("prompt", [...system])
},
async chat(input: string) {
if (store.state.type !== "idle") return
abort = new AbortController()
setStore(
produce((s) => {
s.state = {
type: "loading",
limited: false,
}
s.prompt.push({
role: "user",
content: [
{
type: "text",
text: input,
},
],
})
}),
)
props.onPromptUpdated?.(store.prompt)
while (true) {
if (abort.signal.aborted) {
break
}
const response = await props.generate({
inputFormat: "messages",
prompt: store.prompt,
temperature: 0,
seed: 69,
mode: {
type: "regular",
tools: tools()?.map((tool) => ({
type: "function",
name: tool.name,
description: tool.description,
parameters: {
...tool.inputSchema,
},
})),
},
})
if (abort.signal.aborted) continue
if (!response.err) {
setStore("state", {
type: "loading",
})
if (response.text) {
setStore(
produce((s) => {
s.prompt.push({
role: "assistant",
content: [
{
type: "text",
text: response.text || "",
},
],
})
}),
)
props.onPromptUpdated?.(store.prompt)
}
if (response.finishReason === "stop") {
break
}
if (response.finishReason === "tool-calls") {
for (const item of response.toolCalls || []) {
setStore(
produce((s) => {
s.prompt.push({
role: "assistant",
content: [
{
type: "tool-call",
toolName: item.toolName,
args: JSON.parse(item.args),
toolCallId: item.toolCallId,
},
],
})
}),
)
props.onPromptUpdated?.(store.prompt)
const called = await props.tool.call({
name: item.toolName,
arguments: JSON.parse(item.args),
})
setStore(
produce((s) => {
s.prompt.push({
role: "tool",
content: [
{
type: "tool-result",
toolName: item.toolName,
toolCallId: item.toolCallId,
result: called,
},
],
})
}),
)
props.onPromptUpdated?.(store.prompt)
}
}
continue
}
if (response.err === "context") {
setStore(
produce((s) => {
s.prompt.splice(2, 1)
}),
)
props.onPromptUpdated?.(store.prompt)
}
if (response.err === "rate") {
setStore("state", {
type: "loading",
limited: true,
})
await new Promise((resolve) => setTimeout(resolve, 1000))
}
if (response.err === "balance") {
setStore(
produce((s) => {
s.prompt.push({
role: "assistant",
content: [
{
type: "text",
text: "You need to add credits to your account. Please go to Billing and add credits to continue.",
},
],
})
s.state = { type: "idle" }
}),
)
props.onPromptUpdated?.(store.prompt)
break
}
}
setStore("state", { type: "idle" })
},
async cancel() {
abort.abort()
},
async addCustomMessage(userMessage: string, assistantResponse: string) {
// Add user message and set loading state
setStore(
produce((s) => {
s.prompt.push({
role: "user",
content: [
{
type: "text",
text: userMessage,
},
],
})
s.state = {
type: "loading",
limited: false,
}
}),
)
props.onPromptUpdated?.(store.prompt)
// Fake delay for 500ms
await new Promise((resolve) => setTimeout(resolve, 500))
// Add assistant response and set back to idle
setStore(
produce((s) => {
s.prompt.push({
role: "assistant",
content: [
{
type: "text",
text: assistantResponse,
},
],
})
s.state = { type: "idle" }
}),
)
props.onPromptUpdated?.(store.prompt)
},
}
}
@@ -1,239 +0,0 @@
.root {
display: contents;
[data-slot="messages"] {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
height: 0;
/* This is important for flexbox to allow scrolling */
font-family: var(--font-mono);
color: var(--color-text);
row-gap: var(--space-4);
/* Add consistent spacing between messages */
/* Remove top border for first user message */
&>[data-component="message"][data-user]:first-child::before {
display: none;
}
&:has([data-component="loading"]) [data-component="clear"] {
display: none;
}
}
[data-component="message"] {
width: 100%;
padding: var(--space-2) var(--space-4);
line-height: var(--font-line-height);
white-space: pre-wrap;
align-self: flex-start;
min-height: auto;
/* Allow natural height for all messages */
display: flex;
flex-direction: column;
align-items: flex-start;
/* User message styling */
&[data-user] {
padding: var(--space-6) var(--space-4);
position: relative;
font-weight: 600;
color: var(--color-text);
/* margin: 0.5rem 0; */
}
&[data-user]::before,
&[data-user]::after {
content: "";
position: absolute;
left: var(--space-4);
right: var(--space-4);
height: var(--space-px);
background-color: var(--color-border);
z-index: 1;
/* Ensure borders appear above other content */
}
&[data-user]::before {
top: 0;
}
&[data-user]::after {
bottom: 0;
}
&[data-assistant] {
color: var(--color-text);
}
}
[data-component="tool"] {
display: flex;
width: 100%;
padding: 0 var(--space-4);
margin-left: 0;
flex-direction: column;
opacity: 0.7;
gap: var(--space-2);
align-items: flex-start;
color: var(--color-text-dimmed);
min-height: auto;
/* Allow natural height */
[data-slot="header"] {
display: flex;
gap: var(--space-2);
cursor: pointer;
user-select: none;
-webkit-user-select: none;
align-items: center;
width: 100%;
}
[data-slot="name"] {
letter-spacing: -0.03125rem;
text-transform: uppercase;
font-weight: 500;
font-size: var(--font-size-sm);
}
[data-slot="expand"] {
font-size: var(--font-size-sm);
}
[data-slot="content"] {
padding: 0;
line-height: var(--font-line-height);
font-size: var(--font-size-sm);
white-space: pre-wrap;
display: none;
width: 100%;
}
[data-slot="output"] {
margin-top: var(--space-1);
}
&[data-expanded="true"] [data-slot="content"] {
display: block;
}
&[data-expanded="true"] [data-slot="expand"] {
transform: rotate(45deg);
}
}
[data-component="loading"] {
padding: var(--space-4) var(--space-4) var(--space-8);
height: 1.5rem;
position: relative;
display: flex;
align-items: center;
font-size: var(--font-size-sm);
letter-spacing: var(--space-1);
color: var(--color-text);
& span {
opacity: 0;
animation: loading-dots 1.4s linear infinite;
}
& span:nth-child(2) {
animation-delay: 0.2s;
}
& span:nth-child(3) {
animation-delay: 0.4s;
}
}
[data-component="clear"] {
position: relative;
padding: var(--space-4) var(--space-4);
&::before {
content: "";
position: absolute;
left: var(--space-4);
right: var(--space-4);
top: 0;
height: var(--space-px);
background-color: var(--color-border);
z-index: 1;
}
& [data-component="button"] {
padding-left: 0;
}
}
[data-slot="footer"] {
display: flex;
flex-direction: column;
padding: 0;
border-top: 2px solid var(--color-border);
position: sticky;
bottom: 0;
z-index: 10;
/* Ensure it's above other content */
margin-top: auto;
/* Push to bottom if content is short */
width: 100%;
}
[data-component="chat"] {
display: flex;
padding: var(--space-0-5) 0;
align-items: center;
width: 100%;
height: 100%;
textarea {
--padding-y: var(--space-4);
--line-height: 1.5;
--text-height: calc(var(--line-height) * var(--font-size-lg));
--height: calc(var(--text-height) + var(--padding-y) * 2);
width: 100%;
resize: none;
line-height: var(--line-height);
height: var(--height);
min-height: var(--height);
max-height: calc(5 * var(--text-height) + var(--padding-y) * 2);
padding: var(--padding-y) var(--space-4);
border-radius: 0;
background-color: transparent;
color: var(--color-text);
border: none;
outline: none;
font-size: var(--font-size-lg);
}
textarea::placeholder {
color: var(--color-text-dimmed);
opacity: 0.75;
}
textarea:focus {
outline: 0;
}
& [data-component="button"] {
height: 100%;
}
}
}
@keyframes loading-dots {
0%,
100% {
opacity: 0;
}
40%,
60% {
opacity: 1;
}
}
-18
View File
@@ -1,18 +0,0 @@
import { Button } from "../../ui/button"
import { IconArrowRight } from "../../ui/svg/icons"
import { createSignal, For } from "solid-js"
import { createToolCaller } from "./components/tool"
import { useApi } from "../components/context-api"
import { useWorkspace } from "../components/context-workspace"
import style from "./index.module.css"
export default function Index() {
const api = useApi()
const workspace = useWorkspace()
return (
<div class={style.root}>
<h1>Hello</h1>
</div>
)
}

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