Compare commits
635 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e086e3ab9 | |||
| 2f5faae34b | |||
| e3ad6a0698 | |||
| b536b45536 | |||
| 81c245035f | |||
| dda7059e57 | |||
| 0cca75ef48 | |||
| ee1f55dbe2 | |||
| 2fa50190e5 | |||
| 662b6b1258 | |||
| f0dbe40522 | |||
| 41c54f629c | |||
| 4503201b15 | |||
| 120151ee38 | |||
| 4d2e556713 | |||
| 54a5d3a9eb | |||
| 22dc6b6ec9 | |||
| b5c6ddcd04 | |||
| e03ad6c42e | |||
| 33457d8472 | |||
| 888105e60f | |||
| b7b490f67c | |||
| f6ed59bf45 | |||
| 83991bee88 | |||
| 29142eb940 | |||
| aab47714c9 | |||
| 9b2b610920 | |||
| 468cec545a | |||
| 3c82fb6818 | |||
| 8a2f370eda | |||
| 636133e6cb | |||
| 6cf8784ecf | |||
| 95d5e1f231 | |||
| 979bad3e64 | |||
| 300d0474a3 | |||
| d4379c8c93 | |||
| a9b230f419 | |||
| 07cffebc8f | |||
| f1de1634d6 | |||
| 8d8663399d | |||
| 83eb61fd5f | |||
| b8e7d06356 | |||
| 4543765e3a | |||
| 28f5cbbfe9 | |||
| ca3c22dc12 | |||
| 49110f7412 | |||
| 14dcf43246 | |||
| 84e4afc0bd | |||
| 1cc8e9a36d | |||
| 43b429db93 | |||
| 6248c1e720 | |||
| 772e1851c0 | |||
| 5ab2ff9589 | |||
| a0062d4661 | |||
| ef7f1f0761 | |||
| 96b5a079ff | |||
| 0e58f488df | |||
| fa7416687b | |||
| c3ab370344 | |||
| bb60aa3060 | |||
| 34fa8cadd6 | |||
| edd459ec00 | |||
| 177875f624 | |||
| fdaa7f287c | |||
| fed659c582 | |||
| bffc612a4e | |||
| b97b15e0fe | |||
| fcb972de19 | |||
| 4478195ea8 | |||
| 54c4a783b3 | |||
| c091cbb624 | |||
| d4f8fd867a | |||
| a2884b08cc | |||
| 60faa26a15 | |||
| d8510ab452 | |||
| 3c23b92bea | |||
| 021fd3fcb5 | |||
| fa3253d1b6 | |||
| a1cc8f6cdb | |||
| 1bcc02442a | |||
| 1d782dc19a | |||
| 879d02f86c | |||
| 028d589ea0 | |||
| cdbdb96218 | |||
| f22c93ba1b | |||
| b34d5c959b | |||
| 32e6a552c0 | |||
| d6afebf22a | |||
| b32cb2b932 | |||
| 265f427d2a | |||
| 16520261f4 | |||
| 65b2cf73d7 | |||
| 95069af03f | |||
| 3e4ebb6e5d | |||
| 9147108675 | |||
| ea9dd4e9e2 | |||
| 4a6e36a404 | |||
| db2bb32bcf | |||
| 1384a5e3e6 | |||
| 167aea6aaf | |||
| 142056e9af | |||
| 241c366164 | |||
| 3cd7ae0807 | |||
| 299a74061a | |||
| e3d5af2855 | |||
| bbfa72552a | |||
| 8dfdd3927e | |||
| 02e326f87f | |||
| 35b03e4cb3 | |||
| b3555cda30 | |||
| 04bd98cf4d | |||
| f3e31130ba | |||
| 671e91f201 | |||
| d334ead84a | |||
| 160428d2d4 | |||
| 0a1f9accd7 | |||
| a74f27e59a | |||
| f76cdfff9b | |||
| 05974ea109 | |||
| 6cfce1e4da | |||
| e20093678f | |||
| 7f8f46f9fe | |||
| 2dbdecb0f7 | |||
| 53b0a25085 | |||
| 093e64eb54 | |||
| f34a3b6f67 | |||
| 484c90ed00 | |||
| f49694a543 | |||
| fb88705bdc | |||
| 90d85e6393 | |||
| d13822d26e | |||
| a890288900 | |||
| 31d6e303a6 | |||
| 199c42f726 | |||
| 3211594821 | |||
| 01050a430f | |||
| 0b565b18c4 | |||
| 0791b077d7 | |||
| 2fb59fee8e | |||
| 8206da4d9e | |||
| 0c6bda8255 | |||
| 1e063e7937 | |||
| 37c34fd39c | |||
| 3a60ae98f3 | |||
| feeb49a42b | |||
| ad84355ebc | |||
| e2397a343d | |||
| 8e769dcac0 | |||
| 9ad0477af6 | |||
| c5eafd5722 | |||
| caf9fdc893 | |||
| be4155a838 | |||
| b00326a75a | |||
| 4cf0aebb2e | |||
| a5a39dada7 | |||
| 866f22b077 | |||
| ec7ab16ce4 | |||
| e5b06a2d95 | |||
| fae49aaf88 | |||
| 274c8baa34 | |||
| bfb36a8566 | |||
| 8487346d3f | |||
| 54b99cd88a | |||
| 786db364d2 | |||
| 863e7a093e | |||
| ca87b2806f | |||
| 2958c6b53c | |||
| 8b5394e031 | |||
| da92ee5f09 | |||
| 80de5d489f | |||
| ab17578516 | |||
| e764525578 | |||
| a1c4f345a8 | |||
| 526a8ea19a | |||
| 4be9f7ab9c | |||
| 8e40160934 | |||
| e97ed735d9 | |||
| 6d21525e71 | |||
| b4f809559e | |||
| 33109bac4d | |||
| f072ab3276 | |||
| 3b746162d2 | |||
| 6df19f1828 | |||
| fba56d6871 | |||
| 1472efcbfe | |||
| 56a5d58945 | |||
| f50a57041f | |||
| f3da73553c | |||
| 9a26b3058f | |||
| a09be7cf74 | |||
| 91a9e455e2 | |||
| c391c6d3f3 | |||
| ca562266b7 | |||
| c69c9327da | |||
| f5e2c596d4 | |||
| e9bad39a7e | |||
| 42c7880858 | |||
| 017a440a70 | |||
| 1ab9547bb2 | |||
| a4e46e6e18 | |||
| 680d52016c | |||
| 6ebbcb3179 | |||
| 437de4ee36 | |||
| a5b28b5cef | |||
| 9bf024f8be | |||
| 189d0e5fb2 | |||
| b1b402faa7 | |||
| c5413c8c8d | |||
| da1e8484a9 | |||
| 4818bc5426 | |||
| 8a8c6b14af | |||
| 0e31bbcd93 | |||
| 913b3434d8 | |||
| 1c01ee4834 | |||
| 005d6e0bde | |||
| 37c0c1f358 | |||
| 2a132f86d6 | |||
| 50ba0b380b | |||
| 6cccbdccd3 | |||
| d0ad09d798 | |||
| 4fa4246c10 | |||
| 0fe72864f2 | |||
| ce5b3126d3 | |||
| 26606ccbf7 | |||
| fce9e79d38 | |||
| 6759674c0f | |||
| a9799136fe | |||
| 7a29af4e30 | |||
| d398001f96 | |||
| e68747a64a | |||
| d62ce482da | |||
| f9f41e205d | |||
| 80597cd3fd | |||
| 48f81fe4d3 | |||
| a96c2ce65c | |||
| 6f604bd0f9 | |||
| 42c1cd6a85 | |||
| 33a831d2be | |||
| d70201cd93 | |||
| 9f1a75e938 | |||
| 2b77a7f714 | |||
| 5974a53071 | |||
| 3d61cc5d2b | |||
| a22a2f0f37 | |||
| be65ed6f88 | |||
| e88264075a | |||
| 041a080a13 | |||
| 9d7c5efb9b | |||
| 8863a499a9 | |||
| 15d21bf04a | |||
| 5e738ce7d3 | |||
| 641e9ff664 | |||
| d249766777 | |||
| 6cf4b7f00b | |||
| 6183398543 | |||
| ff786d9139 | |||
| 4767276a0e | |||
| 71bab45065 | |||
| cb48813c95 | |||
| 520cd02dd5 | |||
| afe741b63e | |||
| f3b224090c | |||
| 3b7b7f4bea | |||
| 3a4d3b249f | |||
| 55a6fcdd3f | |||
| 4132fcc1b2 | |||
| 37082b2176 | |||
| b9f009c529 | |||
| 601f610eb7 | |||
| 2e2bdd46b4 | |||
| 3a28ce9b0a | |||
| bb6fc2a1fd | |||
| ad76fa8616 | |||
| bdac7d10dd | |||
| 0ecfdd7501 | |||
| a9758e0db5 | |||
| e98f915fd5 | |||
| 07f0fea4bf | |||
| 6a43afc4e7 | |||
| c01eefc729 | |||
| 5d4ccc8883 | |||
| 98b5390a22 | |||
| c040baae11 | |||
| 754cc66741 | |||
| 6ef0b991ec | |||
| f6ca06b8ea | |||
| 4c198940d5 | |||
| 2e938d9da1 | |||
| b840a40759 | |||
| a1d40f8f28 | |||
| 575d76fa06 | |||
| b75456f5dd | |||
| eb69cc3943 | |||
| e524209352 | |||
| e1c897c1ae | |||
| 39f54e83e1 | |||
| d34c974996 | |||
| c203891b84 | |||
| 591bd2a4e3 | |||
| 94f35130f7 | |||
| f26873f5de | |||
| 66b18959eb | |||
| deacf5991a | |||
| 25623d1f84 | |||
| de9f144858 | |||
| 0ad8738933 | |||
| db5744bbc4 | |||
| b87ba57819 | |||
| 802389a90e | |||
| 4880b08b8a | |||
| 80555f13e0 | |||
| 113c49457f | |||
| e1ec815d1b | |||
| 2ed17f4877 | |||
| 80118212da | |||
| 9b3760247a | |||
| a2d652b13d | |||
| 5c491758f5 | |||
| 5f750b7368 | |||
| e2dc5a8faf | |||
| 72d10a0823 | |||
| 7623b33f31 | |||
| 9b331a917e | |||
| d51b4263ab | |||
| 34a2dcb80a | |||
| 8cbd59296e | |||
| 83974e0c95 | |||
| 59d43fa5da | |||
| e01afb407c | |||
| f0f55bc75f | |||
| 2860a2bb1a | |||
| 9b564f0b73 | |||
| 2437ce3f8b | |||
| fa8a46326a | |||
| 652429377b | |||
| 99af6146d5 | |||
| 020e0ca039 | |||
| 0439072420 | |||
| 49ad2efef6 | |||
| 0e303e6508 | |||
| bcd2fd68b7 | |||
| d0d67029f4 | |||
| a34d020bc6 | |||
| 96fbc37f01 | |||
| 89e3a72ae1 | |||
| b9ebcea82c | |||
| f31f92119d | |||
| da9b2a18b9 | |||
| 2b258b1473 | |||
| 6f894950a6 | |||
| 9049295cc9 | |||
| 4526b14b17 | |||
| f768313c4f | |||
| d9c1b2cc90 | |||
| 6cfcf51752 | |||
| dff8e77eb6 | |||
| 6e854a4df4 | |||
| 2f8984fadb | |||
| c84918cb47 | |||
| 05bb065d00 | |||
| 3742997889 | |||
| daf0305203 | |||
| 307982a099 | |||
| ba416e787b | |||
| b71cae63f1 | |||
| c92f7c6630 | |||
| 4a444e9c9b | |||
| 623d132772 | |||
| d127a1c4eb | |||
| c9cca48d08 | |||
| 3944930fc0 | |||
| 825c0b64af | |||
| d7af7dd3fe | |||
| b112216241 | |||
| 87237b6462 | |||
| 5f5f9dad87 | |||
| aa8b3ce1ee | |||
| a65e593ab4 | |||
| 5d9058eb74 | |||
| a850320fad | |||
| ddbb217d0d | |||
| ab150be7c3 | |||
| a203fb8ccc | |||
| acc084c9ea | |||
| 3ee213081e | |||
| 15bf40bc10 | |||
| a33e3e25b6 | |||
| 658faab2bf | |||
| 797045ee29 | |||
| c8f8d67a88 | |||
| 182e32e4f7 | |||
| 5ea989fb74 | |||
| 45c778b90d | |||
| 47cbb650a0 | |||
| e91371c6a5 | |||
| 9d17314309 | |||
| 3982be4310 | |||
| 4c998d4f4f | |||
| f7849c2d59 | |||
| 463002185b | |||
| 53a80eac1e | |||
| 01b6bf5bb7 | |||
| d8f3b60625 | |||
| cf8e16018d | |||
| 674797bd48 | |||
| 1f9610e266 | |||
| ae86ef519c | |||
| 2391e338b4 | |||
| 1e9399fbee | |||
| e9f74b867f | |||
| 5079556896 | |||
| 7f0e68b933 | |||
| 0c21ca5318 | |||
| 0117c72a2c | |||
| e3eb9e5435 | |||
| d941be3f1f | |||
| 36e5ae804e | |||
| c9b90dd184 | |||
| 8270a1e4b1 | |||
| 7f9c992993 | |||
| b6524c0982 | |||
| 425c0f1bab | |||
| d20d0c5a95 | |||
| 5af3c05d41 | |||
| df4a9295c0 | |||
| 8cbfc581b5 | |||
| 4bb350a09b | |||
| 17c5b9c12c | |||
| 1f8580553c | |||
| f92b2b76dc | |||
| 1d1a1ddcbf | |||
| dfe5fd8d97 | |||
| ed9fba99c9 | |||
| f100777199 | |||
| f41b7bbd0a | |||
| e35ea2d448 | |||
| bab17d7520 | |||
| 051d7d7936 | |||
| b638dafe5f | |||
| e387b1f16c | |||
| 71a68dd56d | |||
| 3ee8ebd3d3 | |||
| ef298b2f18 | |||
| 3cc08494a5 | |||
| afcdabd095 | |||
| efaba6c5b8 | |||
| 874715838a | |||
| 167eb9ddfa | |||
| fba344718f | |||
| cdd906e32e | |||
| ff0ef3bb43 | |||
| 0095832be3 | |||
| 406ccf9b87 | |||
| f90d6238ed | |||
| f004a0b8c3 | |||
| 49423da081 | |||
| 364cf5b429 | |||
| b2f24e38ed | |||
| 49037e7b28 | |||
| c66832d299 | |||
| 7398b4ce70 | |||
| a61b2026eb | |||
| 69ade34c2c | |||
| fbca5441f6 | |||
| e4680caebb | |||
| e760d28c5a | |||
| 7d5f0f9d18 | |||
| 515f4e8642 | |||
| f2b36b9234 | |||
| f224978bbc | |||
| 8819a37a05 | |||
| 769dff00ba | |||
| d1be7a984e | |||
| 3e30607a6d | |||
| d08e58279d | |||
| 7bc542abff | |||
| ed50c36789 | |||
| 98cf65b425 | |||
| 5406083850 | |||
| 91ae9b33d3 | |||
| a42175c067 | |||
| 8497145db2 | |||
| 89544fad61 | |||
| 1151accf4b | |||
| 1ae3f1830b | |||
| 1e958b62ad | |||
| fdf5367f4f | |||
| 0e8842a007 | |||
| 060994f393 | |||
| 61b605e724 | |||
| 61d9dc9511 | |||
| 76275e533e | |||
| 98e2910e82 | |||
| 2941137416 | |||
| b3c0285db3 | |||
| 805aeff83c | |||
| bce2ec5c10 | |||
| 292e9d90ca | |||
| 2b4441a0d1 | |||
| 8f3a94df92 | |||
| 4415220555 | |||
| a3a04d8a54 | |||
| 792e2b164b | |||
| 5859dcdc00 | |||
| 3c2b0f4dd0 | |||
| 9738886620 | |||
| f3dccad54b | |||
| b3a8dbd0d9 | |||
| d93694a979 | |||
| 8a4d4152ce | |||
| f12386e558 | |||
| 94aeb7b7fe | |||
| a35466cdb3 | |||
| 170c7ad67a | |||
| 7a62ab7675 | |||
| 1586d757dc | |||
| d043526200 | |||
| aaf0bc14ba | |||
| f2d9bb7ee3 | |||
| de41703e20 | |||
| 2c24bfb7b3 | |||
| 47a37b7dd6 | |||
| bdbf31f0b9 | |||
| 4e6560efb9 | |||
| f2f6efdd35 | |||
| b106787a50 | |||
| e1b2ce483f | |||
| c42d94c465 | |||
| f879a94c95 | |||
| 6d05d5a7c3 | |||
| 2c5003e3fc | |||
| 1b22acbc58 | |||
| 7648a2d790 | |||
| 789ff3c59b | |||
| 259858df5f | |||
| 289f6af3de | |||
| cded7ee9fd | |||
| c02721ce6b | |||
| 5d7a9a98b3 | |||
| ce8bf37f15 | |||
| f33dff8772 | |||
| 3a6a26981a | |||
| d7569d79c6 | |||
| ed3518d075 | |||
| d03a73a8d3 | |||
| 1e11805efc | |||
| a8d5787e8e | |||
| 9ae6af8856 | |||
| e7bb99baab | |||
| 1da298e755 | |||
| c40e68496d | |||
| 8e160488ff | |||
| 2de5127417 | |||
| 2b5a33e476 | |||
| bf8cd3bd12 | |||
| 72afeb9f54 | |||
| 333ea6ec4b | |||
| 05d0e86f10 | |||
| e3a62736db | |||
| c24e3c18e0 | |||
| caea293759 | |||
| 36172979b4 | |||
| cc07f7a186 | |||
| bbfa60c787 | |||
| 76b4065f17 | |||
| 1cdd24fbc7 | |||
| f6be348bf7 | |||
| 013694832f | |||
| 0130bde1ed | |||
| 0b3e5f5bd4 | |||
| 921f5ee5bd | |||
| 9ae05fea12 | |||
| 80cd75c4fb | |||
| cdc5f209dc | |||
| 3ad983db0f | |||
| 5601466fe1 | |||
| d63d0c4c44 | |||
| bd2ec29b65 | |||
| 0697dcc1d9 | |||
| 8d874b839d | |||
| 08bd75bb6e | |||
| 23e7a95083 | |||
| 6b1c64bcc7 | |||
| 633d1d286a | |||
| 9004b91bf9 | |||
| bd2cb4b5b9 | |||
| 6fe85c66e7 | |||
| c08f5a7902 | |||
| 36f201d5d3 | |||
| 0b007b9c77 | |||
| 21cfe9a243 | |||
| 65055c7687 | |||
| 2af1bbb828 | |||
| 635324d386 | |||
| d39d52d95d | |||
| b12ca55594 | |||
| e090223b13 | |||
| 939ae03f42 | |||
| 0d8d324ac6 | |||
| fde04bbf85 | |||
| 124bd57c50 | |||
| c571283ac2 | |||
| 94923948e1 | |||
| 4385fb3219 | |||
| f463ce6694 | |||
| 57a2210d8d | |||
| 5acf0cba60 | |||
| a6db4f652c | |||
| 50d8fbbb40 | |||
| b8383f5590 | |||
| 781b296180 | |||
| 6419973667 | |||
| 8f8b403bbc | |||
| fa5840cf75 | |||
| 1d467dfda3 | |||
| 3aef4dedcf | |||
| a1f6147506 | |||
| b28c1e0c03 | |||
| ef15976f5d | |||
| c185dc84d6 | |||
| 6bb1c84f7f | |||
| eb9877ee20 | |||
| f8e05a0d9a | |||
| 49b593d9dd | |||
| 230917bbbf | |||
| 795b369219 | |||
| cfdd687216 | |||
| afd9ad0560 | |||
| 904061c243 | |||
| 005b8ac167 | |||
| e7258e38ae | |||
| 8daa6e774a | |||
| 796bbf4d66 | |||
| 7844cacb25 | |||
| 4b0ea68d7a |
@@ -0,0 +1,37 @@
|
||||
name: build
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- run: git fetch --force --tags
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ">=1.23.2"
|
||||
cache: true
|
||||
cache-dependency-path: go.sum
|
||||
|
||||
- run: go mod download
|
||||
|
||||
- uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: latest
|
||||
args: build --snapshot --clean
|
||||
@@ -0,0 +1,25 @@
|
||||
name: deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dontlook
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- run: bun install
|
||||
|
||||
- run: bun sst deploy --stage=dev
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
@@ -0,0 +1,42 @@
|
||||
name: publish
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- run: git fetch --force --tags
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ">=1.24.0"
|
||||
cache: true
|
||||
cache-dependency-path: go.sum
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.2.16
|
||||
|
||||
- run: |
|
||||
bun install
|
||||
./script/publish.ts
|
||||
working-directory: ./packages/opencode
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }}
|
||||
AUR_KEY: ${{ secrets.AUR_KEY }}
|
||||
NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
@@ -0,0 +1,5 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
.opencode
|
||||
.sst
|
||||
.env
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 OpenCode
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,658 @@
|
||||
◧ opencode
|
||||
|
||||

|
||||
|
||||
> **⚠️ Notice:** We are in progress of a complete overhaul in the `dontlook` branch - should be released mid June. The README below is for the current version
|
||||
|
||||
A powerful terminal-based AI assistant for developers, providing intelligent coding assistance directly in your terminal.
|
||||
|
||||
## Overview
|
||||
|
||||
OpenCode is a Go-based CLI application that brings AI assistance to your terminal. It provides a TUI (Terminal User Interface) for interacting with various AI models to help with coding tasks, debugging, and more.
|
||||
|
||||
## Features
|
||||
|
||||
- **Interactive TUI**: Built with [Bubble Tea](https://github.com/charmbracelet/bubbletea) for a smooth terminal experience
|
||||
- **Multiple AI Providers**: Support for OpenAI, Anthropic Claude, Google Gemini, AWS Bedrock, Groq, Azure OpenAI, and OpenRouter
|
||||
- **Session Management**: Save and manage multiple conversation sessions
|
||||
- **Tool Integration**: AI can execute commands, search files, and modify code
|
||||
- **Vim-like Editor**: Integrated editor with text input capabilities
|
||||
- **Persistent Storage**: SQLite database for storing conversations and sessions
|
||||
- **LSP Integration**: Language Server Protocol support for code intelligence
|
||||
- **File Change Tracking**: Track and visualize file changes during sessions
|
||||
- **External Editor Support**: Open your preferred editor for composing messages
|
||||
- **Named Arguments for Custom Commands**: Create powerful custom commands with multiple named placeholders
|
||||
|
||||
## Installation
|
||||
|
||||
### Using the Install Script
|
||||
|
||||
```bash
|
||||
# Install the latest version
|
||||
curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
# Install a specific version
|
||||
curl -fsSL https://opencode.ai/install | VERSION=0.1.0 bash
|
||||
```
|
||||
|
||||
### Using Homebrew (macOS and Linux)
|
||||
|
||||
```bash
|
||||
brew install sst/tap/opencode
|
||||
```
|
||||
|
||||
### Using AUR (Arch Linux)
|
||||
|
||||
```bash
|
||||
# Using yay
|
||||
yay -S opencode-bin
|
||||
|
||||
# Using paru
|
||||
paru -S opencode-bin
|
||||
```
|
||||
|
||||
### Using Go
|
||||
|
||||
```bash
|
||||
go install github.com/sst/opencode@latest
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
OpenCode looks for configuration in the following locations:
|
||||
|
||||
- `$HOME/.opencode.json`
|
||||
- `$XDG_CONFIG_HOME/opencode/.opencode.json`
|
||||
- `./.opencode.json` (local directory)
|
||||
|
||||
### Environment Variables
|
||||
|
||||
You can configure OpenCode using environment variables:
|
||||
|
||||
| Environment Variable | Purpose |
|
||||
| -------------------------- | ------------------------------------------------------ |
|
||||
| `ANTHROPIC_API_KEY` | For Claude models |
|
||||
| `OPENAI_API_KEY` | For OpenAI models |
|
||||
| `GEMINI_API_KEY` | For Google Gemini models |
|
||||
| `VERTEXAI_PROJECT` | For Google Cloud VertexAI (Gemini) |
|
||||
| `VERTEXAI_LOCATION` | For Google Cloud VertexAI (Gemini) |
|
||||
| `GROQ_API_KEY` | For Groq models |
|
||||
| `AWS_ACCESS_KEY_ID` | For AWS Bedrock (Claude) |
|
||||
| `AWS_SECRET_ACCESS_KEY` | For AWS Bedrock (Claude) |
|
||||
| `AWS_REGION` | For AWS Bedrock (Claude) |
|
||||
| `AZURE_OPENAI_ENDPOINT` | For Azure OpenAI models |
|
||||
| `AZURE_OPENAI_API_KEY` | For Azure OpenAI models (optional when using Entra ID) |
|
||||
| `AZURE_OPENAI_API_VERSION` | For Azure OpenAI models |
|
||||
|
||||
### Configuration File Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"directory": ".opencode"
|
||||
},
|
||||
"providers": {
|
||||
"openai": {
|
||||
"apiKey": "your-api-key",
|
||||
"disabled": false
|
||||
},
|
||||
"anthropic": {
|
||||
"apiKey": "your-api-key",
|
||||
"disabled": false
|
||||
},
|
||||
"groq": {
|
||||
"apiKey": "your-api-key",
|
||||
"disabled": false
|
||||
},
|
||||
"openrouter": {
|
||||
"apiKey": "your-api-key",
|
||||
"disabled": false
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"primary": {
|
||||
"model": "claude-3.7-sonnet",
|
||||
"maxTokens": 5000
|
||||
},
|
||||
"task": {
|
||||
"model": "claude-3.7-sonnet",
|
||||
"maxTokens": 5000
|
||||
},
|
||||
"title": {
|
||||
"model": "claude-3.7-sonnet",
|
||||
"maxTokens": 80
|
||||
}
|
||||
},
|
||||
"mcpServers": {
|
||||
"example": {
|
||||
"type": "stdio",
|
||||
"command": "path/to/mcp-server",
|
||||
"env": [],
|
||||
"args": []
|
||||
}
|
||||
},
|
||||
"lsp": {
|
||||
"go": {
|
||||
"disabled": false,
|
||||
"command": "gopls"
|
||||
}
|
||||
},
|
||||
"shell": {
|
||||
"path": "/bin/zsh",
|
||||
"args": ["-l"]
|
||||
},
|
||||
"debug": false,
|
||||
"debugLSP": false
|
||||
}
|
||||
```
|
||||
|
||||
## Supported AI Models
|
||||
|
||||
OpenCode supports a variety of AI models from different providers:
|
||||
|
||||
### OpenAI
|
||||
|
||||
- GPT-4.1 family (gpt-4.1, gpt-4.1-mini, gpt-4.1-nano)
|
||||
- GPT-4.5 Preview
|
||||
- GPT-4o family (gpt-4o, gpt-4o-mini)
|
||||
- O1 family (o1, o1-pro, o1-mini)
|
||||
- O3 family (o3, o3-mini)
|
||||
- O4 Mini
|
||||
|
||||
### Anthropic
|
||||
|
||||
- Claude 3.5 Sonnet
|
||||
- Claude 3.5 Haiku
|
||||
- Claude 3.7 Sonnet
|
||||
- Claude 3 Haiku
|
||||
- Claude 3 Opus
|
||||
|
||||
### Google
|
||||
|
||||
- Gemini 2.5
|
||||
- Gemini 2.5 Flash
|
||||
- Gemini 2.0 Flash
|
||||
- Gemini 2.0 Flash Lite
|
||||
|
||||
### AWS Bedrock
|
||||
|
||||
- Claude 3.7 Sonnet
|
||||
|
||||
### Groq
|
||||
|
||||
- Llama 4 Maverick (17b-128e-instruct)
|
||||
- Llama 4 Scout (17b-16e-instruct)
|
||||
- QWEN QWQ-32b
|
||||
- Deepseek R1 distill Llama 70b
|
||||
- Llama 3.3 70b Versatile
|
||||
|
||||
### Azure OpenAI
|
||||
|
||||
- GPT-4.1 family (gpt-4.1, gpt-4.1-mini, gpt-4.1-nano)
|
||||
- GPT-4.5 Preview
|
||||
- GPT-4o family (gpt-4o, gpt-4o-mini)
|
||||
- O1 family (o1, o1-mini)
|
||||
- O3 family (o3, o3-mini)
|
||||
- O4 Mini
|
||||
|
||||
### Google Cloud VertexAI
|
||||
|
||||
- Gemini 2.5
|
||||
- Gemini 2.5 Flash
|
||||
|
||||
## Using Bedrock Models
|
||||
|
||||
To use bedrock models with OpenCode you need three things.
|
||||
|
||||
1. Valid AWS credentials (the env vars: `AWS_SECRET_KEY_ID`, `AWS_SECRET_ACCESS_KEY` and `AWS_REGION`)
|
||||
2. Access to the corresponding model in AWS Bedrock in your region.
|
||||
a. You can request access in the AWS console on the Bedrock -> "Model access" page.
|
||||
3. A correct configuration file. You don't need the `providers` key. Instead you have to prefix your models per agent with `bedrock.` and then a valid model. For now only Claude 3.7 is supported.
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"primary": {
|
||||
"model": "bedrock.claude-3.7-sonnet",
|
||||
"maxTokens": 5000,
|
||||
"reasoningEffort": ""
|
||||
},
|
||||
"task": {
|
||||
"model": "bedrock.claude-3.7-sonnet",
|
||||
"maxTokens": 5000,
|
||||
"reasoningEffort": ""
|
||||
},
|
||||
"title": {
|
||||
"model": "bedrock.claude-3.7-sonnet",
|
||||
"maxTokens": 80,
|
||||
"reasoningEffort": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Interactive Mode Usage
|
||||
|
||||
```bash
|
||||
# Start OpenCode
|
||||
opencode
|
||||
|
||||
# Start with debug logging
|
||||
opencode -d
|
||||
|
||||
# Start with a specific working directory
|
||||
opencode -c /path/to/project
|
||||
```
|
||||
|
||||
## Non-interactive Prompt Mode
|
||||
|
||||
You can run OpenCode in non-interactive mode by passing a prompt directly as a command-line argument or by piping text into the command. This is useful for scripting, automation, or when you want a quick answer without launching the full TUI.
|
||||
|
||||
```bash
|
||||
# Run a single prompt and print the AI's response to the terminal
|
||||
opencode -p "Explain the use of context in Go"
|
||||
|
||||
# Pipe input to OpenCode (equivalent to using -p flag)
|
||||
echo "Explain the use of context in Go" | opencode
|
||||
|
||||
# Get response in JSON format
|
||||
opencode -p "Explain the use of context in Go" -f json
|
||||
# Or with piped input
|
||||
echo "Explain the use of context in Go" | opencode -f json
|
||||
|
||||
# Run without showing the spinner
|
||||
opencode -p "Explain the use of context in Go" -q
|
||||
# Or with piped input
|
||||
echo "Explain the use of context in Go" | opencode -q
|
||||
|
||||
# Enable verbose logging to stderr
|
||||
opencode -p "Explain the use of context in Go" --verbose
|
||||
# Or with piped input
|
||||
echo "Explain the use of context in Go" | opencode --verbose
|
||||
|
||||
# Restrict the agent to only use specific tools
|
||||
opencode -p "Explain the use of context in Go" --allowedTools=view,ls,glob
|
||||
# Or with piped input
|
||||
echo "Explain the use of context in Go" | opencode --allowedTools=view,ls,glob
|
||||
|
||||
# Prevent the agent from using specific tools
|
||||
opencode -p "Explain the use of context in Go" --excludedTools=bash,edit
|
||||
# Or with piped input
|
||||
echo "Explain the use of context in Go" | opencode --excludedTools=bash,edit
|
||||
```
|
||||
|
||||
In this mode, OpenCode will process your prompt, print the result to standard output, and then exit. All permissions are auto-approved for the session.
|
||||
|
||||
### Tool Restrictions
|
||||
|
||||
You can control which tools the AI assistant has access to in non-interactive mode:
|
||||
|
||||
- `--allowedTools`: Comma-separated list of tools that the agent is allowed to use. Only these tools will be available.
|
||||
- `--excludedTools`: Comma-separated list of tools that the agent is not allowed to use. All other tools will be available.
|
||||
|
||||
These flags are mutually exclusive - you can use either `--allowedTools` or `--excludedTools`, but not both at the same time.
|
||||
|
||||
### Output Formats
|
||||
|
||||
OpenCode supports the following output formats in non-interactive mode:
|
||||
|
||||
| Format | Description |
|
||||
| ------ | ------------------------------- |
|
||||
| `text` | Plain text output (default) |
|
||||
| `json` | Output wrapped in a JSON object |
|
||||
|
||||
The output format is implemented as a strongly-typed `OutputFormat` in the codebase, ensuring type safety and validation when processing outputs.
|
||||
|
||||
## Command-line Flags
|
||||
|
||||
| Flag | Short | Description |
|
||||
| ----------------- | ----- | --------------------------------------------------- |
|
||||
| `--help` | `-h` | Display help information |
|
||||
| `--debug` | `-d` | Enable debug mode |
|
||||
| `--cwd` | `-c` | Set current working directory |
|
||||
| `--prompt` | `-p` | Run a single prompt in non-interactive mode |
|
||||
| `--output-format` | `-f` | Output format for non-interactive mode (text, json) |
|
||||
| `--quiet` | `-q` | Hide spinner in non-interactive mode |
|
||||
| `--verbose` | | Display logs to stderr in non-interactive mode |
|
||||
| `--allowedTools` | | Restrict the agent to only use specified tools |
|
||||
| `--excludedTools` | | Prevent the agent from using specified tools |
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
### Global Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
| -------- | ------------------------------------------------------- |
|
||||
| `Ctrl+C` | Quit application |
|
||||
| `Ctrl+?` | Toggle help dialog |
|
||||
| `?` | Toggle help dialog (when not in editing mode) |
|
||||
| `Ctrl+L` | View logs |
|
||||
| `Ctrl+A` | Switch session |
|
||||
| `Ctrl+K` | Command dialog |
|
||||
| `Ctrl+O` | Toggle model selection dialog |
|
||||
| `Esc` | Close current overlay/dialog or return to previous mode |
|
||||
|
||||
### Chat Page Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
| -------- | --------------------------------------- |
|
||||
| `Ctrl+N` | Create new session |
|
||||
| `Ctrl+X` | Cancel current operation/generation |
|
||||
| `i` | Focus editor (when not in writing mode) |
|
||||
| `Esc` | Exit writing mode and focus messages |
|
||||
|
||||
### Editor Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
| ------------------- | ----------------------------------------- |
|
||||
| `Ctrl+S` | Send message (when editor is focused) |
|
||||
| `Enter` or `Ctrl+S` | Send message (when editor is not focused) |
|
||||
| `Ctrl+E` | Open external editor |
|
||||
| `Esc` | Blur editor and focus messages |
|
||||
|
||||
### Session Dialog Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
| ---------- | ---------------- |
|
||||
| `↑` or `k` | Previous session |
|
||||
| `↓` or `j` | Next session |
|
||||
| `Enter` | Select session |
|
||||
| `Esc` | Close dialog |
|
||||
|
||||
### Model Dialog Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
| ---------- | ----------------- |
|
||||
| `↑` or `k` | Move up |
|
||||
| `↓` or `j` | Move down |
|
||||
| `←` or `h` | Previous provider |
|
||||
| `→` or `l` | Next provider |
|
||||
| `Esc` | Close dialog |
|
||||
|
||||
### Permission Dialog Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
| ----------------------- | ---------------------------- |
|
||||
| `←` or `left` | Switch options left |
|
||||
| `→` or `right` or `tab` | Switch options right |
|
||||
| `Enter` or `space` | Confirm selection |
|
||||
| `a` | Allow permission |
|
||||
| `A` | Allow permission for session |
|
||||
| `d` | Deny permission |
|
||||
|
||||
### Logs Page Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
| ------------------ | ------------------- |
|
||||
| `Backspace` or `q` | Return to chat page |
|
||||
|
||||
## AI Assistant Tools
|
||||
|
||||
OpenCode's AI assistant has access to various tools to help with coding tasks:
|
||||
|
||||
### File and Code Tools
|
||||
|
||||
| Tool | Description | Parameters |
|
||||
| ------------- | --------------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| `glob` | Find files by pattern | `pattern` (required), `path` (optional) |
|
||||
| `grep` | Search file contents | `pattern` (required), `path` (optional), `include` (optional), `literal_text` (optional) |
|
||||
| `ls` | List directory contents | `path` (optional), `ignore` (optional array of patterns) |
|
||||
| `view` | View file contents | `file_path` (required), `offset` (optional), `limit` (optional) |
|
||||
| `write` | Write to files | `file_path` (required), `content` (required) |
|
||||
| `edit` | Edit files | Various parameters for file editing |
|
||||
| `patch` | Apply patches to files | `file_path` (required), `diff` (required) |
|
||||
| `diagnostics` | Get diagnostics information | `file_path` (optional) |
|
||||
|
||||
### Other Tools
|
||||
|
||||
| Tool | Description | Parameters |
|
||||
| ------- | ------------------------------- | ----------------------------------------------------------- |
|
||||
| `bash` | Execute shell commands | `command` (required), `timeout` (optional) |
|
||||
| `fetch` | Fetch data from URLs | `url` (required), `format` (required), `timeout` (optional) |
|
||||
| `agent` | Run sub-tasks with the AI agent | `prompt` (required) |
|
||||
|
||||
### Shell Configuration
|
||||
|
||||
OpenCode allows you to configure the shell used by the `bash` tool. By default, it uses:
|
||||
|
||||
1. The shell specified in the config file (if provided)
|
||||
2. The shell from the `$SHELL` environment variable (if available)
|
||||
3. Falls back to `/bin/bash` if neither of the above is available
|
||||
|
||||
To configure a custom shell, add a `shell` section to your `.opencode.json` configuration file:
|
||||
|
||||
```json
|
||||
{
|
||||
"shell": {
|
||||
"path": "/bin/zsh",
|
||||
"args": ["-l"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
You can specify any shell executable and custom arguments:
|
||||
|
||||
```json
|
||||
{
|
||||
"shell": {
|
||||
"path": "/usr/bin/fish",
|
||||
"args": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
OpenCode is built with a modular architecture:
|
||||
|
||||
- **cmd**: Command-line interface using Cobra
|
||||
- **internal/app**: Core application services
|
||||
- **internal/config**: Configuration management
|
||||
- **internal/db**: Database operations and migrations
|
||||
- **internal/llm**: LLM providers and tools integration
|
||||
- **internal/tui**: Terminal UI components and layouts
|
||||
- **internal/logging**: Logging infrastructure
|
||||
- **internal/message**: Message handling
|
||||
- **internal/session**: Session management
|
||||
- **internal/lsp**: Language Server Protocol integration
|
||||
|
||||
## Custom Commands
|
||||
|
||||
OpenCode supports custom commands that can be created by users to quickly send predefined prompts to the AI assistant.
|
||||
|
||||
### Creating Custom Commands
|
||||
|
||||
Custom commands are predefined prompts stored as Markdown files in one of three locations:
|
||||
|
||||
1. **User Commands** (prefixed with `user:`):
|
||||
|
||||
```
|
||||
$XDG_CONFIG_HOME/opencode/commands/
|
||||
```
|
||||
|
||||
(typically `~/.config/opencode/commands/` on Linux/macOS)
|
||||
|
||||
or
|
||||
|
||||
```
|
||||
$HOME/.opencode/commands/
|
||||
```
|
||||
|
||||
2. **Project Commands** (prefixed with `project:`):
|
||||
```
|
||||
<PROJECT DIR>/.opencode/commands/
|
||||
```
|
||||
|
||||
Each `.md` file in these directories becomes a custom command. The file name (without extension) becomes the command ID.
|
||||
|
||||
For example, creating a file at `~/.config/opencode/commands/prime-context.md` with content:
|
||||
|
||||
```markdown
|
||||
RUN git ls-files
|
||||
READ README.md
|
||||
```
|
||||
|
||||
This creates a command called `user:prime-context`.
|
||||
|
||||
### Command Arguments
|
||||
|
||||
OpenCode supports named arguments in custom commands using placeholders in the format `$NAME` (where NAME consists of uppercase letters, numbers, and underscores, and must start with a letter).
|
||||
|
||||
For example:
|
||||
|
||||
```markdown
|
||||
# Fetch Context for Issue $ISSUE_NUMBER
|
||||
|
||||
RUN gh issue view $ISSUE_NUMBER --json title,body,comments
|
||||
RUN git grep --author="$AUTHOR_NAME" -n .
|
||||
RUN grep -R "$SEARCH_PATTERN" $DIRECTORY
|
||||
```
|
||||
|
||||
When you run a command with arguments, OpenCode will prompt you to enter values for each unique placeholder. Named arguments provide several benefits:
|
||||
|
||||
- Clear identification of what each argument represents
|
||||
- Ability to use the same argument multiple times
|
||||
- Better organization for commands with multiple inputs
|
||||
|
||||
### Organizing Commands
|
||||
|
||||
You can organize commands in subdirectories:
|
||||
|
||||
```
|
||||
~/.config/opencode/commands/git/commit.md
|
||||
```
|
||||
|
||||
This creates a command with ID `user:git:commit`.
|
||||
|
||||
### Using Custom Commands
|
||||
|
||||
1. Press `Ctrl+K` to open the command dialog
|
||||
2. Select your custom command (prefixed with either `user:` or `project:`)
|
||||
3. Press Enter to execute the command
|
||||
|
||||
The content of the command file will be sent as a message to the AI assistant.
|
||||
|
||||
## MCP (Model Context Protocol)
|
||||
|
||||
OpenCode implements the Model Context Protocol (MCP) to extend its capabilities through external tools. MCP provides a standardized way for the AI assistant to interact with external services and tools.
|
||||
|
||||
### MCP Features
|
||||
|
||||
- **External Tool Integration**: Connect to external tools and services via a standardized protocol
|
||||
- **Tool Discovery**: Automatically discover available tools from MCP servers
|
||||
- **Multiple Connection Types**:
|
||||
- **Stdio**: Communicate with tools via standard input/output
|
||||
- **SSE**: Communicate with tools via Server-Sent Events
|
||||
- **Security**: Permission system for controlling access to MCP tools
|
||||
|
||||
### Configuring MCP Servers
|
||||
|
||||
MCP servers are defined in the configuration file under the `mcpServers` section:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"example": {
|
||||
"type": "stdio",
|
||||
"command": "path/to/mcp-server",
|
||||
"env": [],
|
||||
"args": []
|
||||
},
|
||||
"web-example": {
|
||||
"type": "sse",
|
||||
"url": "https://example.com/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer token"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### MCP Tool Usage
|
||||
|
||||
Once configured, MCP tools are automatically available to the AI assistant alongside built-in tools. They follow the same permission model as other tools, requiring user approval before execution.
|
||||
|
||||
## LSP (Language Server Protocol)
|
||||
|
||||
OpenCode integrates with Language Server Protocol to provide code intelligence features across multiple programming languages.
|
||||
|
||||
### LSP Features
|
||||
|
||||
- **Multi-language Support**: Connect to language servers for different programming languages
|
||||
- **Diagnostics**: Receive error checking and linting information
|
||||
- **File Watching**: Automatically notify language servers of file changes
|
||||
|
||||
### Configuring LSP
|
||||
|
||||
Language servers are configured in the configuration file under the `lsp` section:
|
||||
|
||||
```json
|
||||
{
|
||||
"lsp": {
|
||||
"go": {
|
||||
"disabled": false,
|
||||
"command": "gopls"
|
||||
},
|
||||
"typescript": {
|
||||
"disabled": false,
|
||||
"command": "typescript-language-server",
|
||||
"args": ["--stdio"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### LSP Integration with AI
|
||||
|
||||
The AI assistant can access LSP features through the `diagnostics` tool, allowing it to:
|
||||
|
||||
- Check for errors in your code
|
||||
- Suggest fixes based on diagnostics
|
||||
|
||||
While the LSP client implementation supports the full LSP protocol (including completions, hover, definition, etc.), currently only diagnostics are exposed to the AI assistant.
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Go 1.24.0 or higher
|
||||
|
||||
### Building from Source
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/sst/opencode.git
|
||||
cd opencode
|
||||
|
||||
# Build
|
||||
go build -o opencode
|
||||
|
||||
# Run
|
||||
./opencode
|
||||
```
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
OpenCode gratefully acknowledges the contributions and support from these key individuals:
|
||||
|
||||
- [@isaacphi](https://github.com/isaacphi) - For the [mcp-language-server](https://github.com/isaacphi/mcp-language-server) project which provided the foundation for our LSP client implementation
|
||||
- [@adamdottv](https://github.com/adamdottv) - For the design direction and UI/UX architecture
|
||||
|
||||
Special thanks to the broader open source community whose tools and libraries have made this project possible.
|
||||
|
||||
## License
|
||||
|
||||
OpenCode is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Here's how you can contribute:
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
|
||||
4. Push to the branch (`git push origin feature/amazing-feature`)
|
||||
5. Open a Pull Request
|
||||
|
||||
Please make sure to update tests as appropriate and follow the existing code style.
|
||||
@@ -0,0 +1,2 @@
|
||||
[install]
|
||||
exact = true
|
||||
@@ -0,0 +1,51 @@
|
||||
export const domain = (() => {
|
||||
if ($app.stage === "production") return "opencode.ai"
|
||||
if ($app.stage === "dev") return "dev.opencode.ai"
|
||||
return `${$app.stage}.dev.opencode.ai`
|
||||
})()
|
||||
|
||||
const bucket = new sst.cloudflare.Bucket("Bucket")
|
||||
|
||||
export const api = new sst.cloudflare.Worker("Api", {
|
||||
domain: `api.${domain}`,
|
||||
handler: "packages/function/src/api.ts",
|
||||
url: true,
|
||||
link: [bucket],
|
||||
transform: {
|
||||
worker: (args) => {
|
||||
args.logpush = true
|
||||
args.bindings = $resolve(args.bindings).apply((bindings) => [
|
||||
...bindings,
|
||||
{
|
||||
name: "SYNC_SERVER",
|
||||
type: "durable_object_namespace",
|
||||
className: "SyncServer",
|
||||
},
|
||||
])
|
||||
args.migrations = {
|
||||
oldTag: "v1",
|
||||
newTag: "v1",
|
||||
//newSqliteClasses: ["SyncServer"],
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// new sst.cloudflare.StaticSite("Web", {
|
||||
// path: "packages/web",
|
||||
// domain,
|
||||
// environment: {
|
||||
// VITE_API_URL: api.url,
|
||||
// },
|
||||
// build: {
|
||||
// command: "bun run build",
|
||||
// output: "dist",
|
||||
// },
|
||||
// })
|
||||
new sst.cloudflare.x.Astro("Web", {
|
||||
domain,
|
||||
path: "packages/web",
|
||||
environment: {
|
||||
VITE_API_URL: api.url,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
APP=opencode
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
ORANGE='\033[38;2;255;140;0m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
requested_version=${VERSION:-}
|
||||
|
||||
os=$(uname -s | tr '[:upper:]' '[:lower:]')
|
||||
if [[ "$os" == "darwin" ]]; then
|
||||
os="mac"
|
||||
fi
|
||||
arch=$(uname -m)
|
||||
|
||||
if [[ "$arch" == "aarch64" ]]; then
|
||||
arch="arm64"
|
||||
fi
|
||||
|
||||
filename="$APP-$os-$arch.tar.gz"
|
||||
|
||||
|
||||
case "$filename" in
|
||||
*"-linux-"*)
|
||||
[[ "$arch" == "x86_64" || "$arch" == "arm64" || "$arch" == "i386" ]] || exit 1
|
||||
;;
|
||||
*"-mac-"*)
|
||||
[[ "$arch" == "x86_64" || "$arch" == "arm64" ]] || exit 1
|
||||
;;
|
||||
*)
|
||||
echo "${RED}Unsupported OS/Arch: $os/$arch${NC}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
INSTALL_DIR=$HOME/.opencode/bin
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
|
||||
if [ -z "$requested_version" ]; then
|
||||
url="https://github.com/sst/opencode/releases/latest/download/$filename"
|
||||
specific_version=$(curl -s https://api.github.com/repos/sst/opencode/releases/latest | awk -F'"' '/"tag_name": "/ {gsub(/^v/, "", $4); print $4}')
|
||||
|
||||
if [[ $? -ne 0 ]]; then
|
||||
echo "${RED}Failed to fetch version information${NC}"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
url="https://github.com/sst/opencode/releases/download/v${requested_version}/$filename"
|
||||
specific_version=$requested_version
|
||||
fi
|
||||
|
||||
print_message() {
|
||||
local level=$1
|
||||
local message=$2
|
||||
local color=""
|
||||
|
||||
case $level in
|
||||
info) color="${GREEN}" ;;
|
||||
warning) color="${YELLOW}" ;;
|
||||
error) color="${RED}" ;;
|
||||
esac
|
||||
|
||||
echo -e "${color}${message}${NC}"
|
||||
}
|
||||
|
||||
check_version() {
|
||||
if command -v opencode >/dev/null 2>&1; then
|
||||
opencode_path=$(which opencode)
|
||||
|
||||
|
||||
## TODO: check if version is installed
|
||||
# installed_version=$(opencode version)
|
||||
installed_version="0.0.1"
|
||||
installed_version=$(echo $installed_version | awk '{print $2}')
|
||||
|
||||
if [[ "$installed_version" != "$specific_version" ]]; then
|
||||
print_message info "Installed version: ${YELLOW}$installed_version."
|
||||
else
|
||||
print_message info "Version ${YELLOW}$specific_version${GREEN} already installed"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
download_and_install() {
|
||||
print_message info "Downloading ${ORANGE}opencode ${GREEN}version: ${YELLOW}$specific_version ${GREEN}..."
|
||||
mkdir -p opencodetmp && cd opencodetmp
|
||||
curl -# -L $url | tar xz
|
||||
mv opencode $INSTALL_DIR
|
||||
cd .. && rm -rf opencodetmp
|
||||
}
|
||||
|
||||
check_version
|
||||
download_and_install
|
||||
|
||||
|
||||
add_to_path() {
|
||||
local config_file=$1
|
||||
local command=$2
|
||||
|
||||
if [[ -w $config_file ]]; then
|
||||
echo -e "\n# opencode" >> "$config_file"
|
||||
echo "$command" >> "$config_file"
|
||||
print_message info "Successfully added ${ORANGE}opencode ${GREEN}to \$PATH in $config_file"
|
||||
else
|
||||
print_message warning "Manually add the directory to $config_file (or similar):"
|
||||
print_message info " $command"
|
||||
fi
|
||||
}
|
||||
|
||||
XDG_CONFIG_HOME=${XDG_CONFIG_HOME:-$HOME/.config}
|
||||
|
||||
current_shell=$(basename "$SHELL")
|
||||
case $current_shell in
|
||||
fish)
|
||||
config_files="$HOME/.config/fish/config.fish"
|
||||
;;
|
||||
zsh)
|
||||
config_files="$HOME/.zshrc $HOME/.zshenv $XDG_CONFIG_HOME/zsh/.zshrc $XDG_CONFIG_HOME/zsh/.zshenv"
|
||||
;;
|
||||
bash)
|
||||
config_files="$HOME/.bashrc $HOME/.bash_profile $HOME/.profile $XDG_CONFIG_HOME/bash/.bashrc $XDG_CONFIG_HOME/bash/.bash_profile"
|
||||
;;
|
||||
ash)
|
||||
config_files="$HOME/.ashrc $HOME/.profile /etc/profile"
|
||||
;;
|
||||
sh)
|
||||
config_files="$HOME/.ashrc $HOME/.profile /etc/profile"
|
||||
;;
|
||||
*)
|
||||
# Default case if none of the above matches
|
||||
config_files="$HOME/.bashrc $HOME/.bash_profile $XDG_CONFIG_HOME/bash/.bashrc $XDG_CONFIG_HOME/bash/.bash_profile"
|
||||
;;
|
||||
esac
|
||||
|
||||
config_file=""
|
||||
for file in $config_files; do
|
||||
if [[ -f $file ]]; then
|
||||
config_file=$file
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -z $config_file ]]; then
|
||||
print_message error "No config file found for $current_shell. Checked files: ${config_files[@]}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then
|
||||
case $current_shell in
|
||||
fish)
|
||||
add_to_path "$config_file" "fish_add_path $INSTALL_DIR"
|
||||
;;
|
||||
zsh)
|
||||
add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
|
||||
;;
|
||||
bash)
|
||||
add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
|
||||
;;
|
||||
ash)
|
||||
add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
|
||||
;;
|
||||
sh)
|
||||
add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
|
||||
;;
|
||||
*)
|
||||
print_message warning "Manually add the directory to $config_file (or similar):"
|
||||
print_message info " export PATH=$INSTALL_DIR:\$PATH"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
if [ -n "${GITHUB_ACTIONS-}" ] && [ "${GITHUB_ACTIONS}" == "true" ]; then
|
||||
echo "$INSTALL_DIR" >> $GITHUB_PATH
|
||||
print_message info "Added $INSTALL_DIR to \$GITHUB_PATH"
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.2.14",
|
||||
"scripts": {
|
||||
"typecheck": "bun run --filter='*' typecheck",
|
||||
"dev": "sst dev"
|
||||
},
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
"packages/*"
|
||||
],
|
||||
"catalog": {
|
||||
"typescript": "5.8.2",
|
||||
"@types/node": "22.13.9",
|
||||
"zod": "3.24.2",
|
||||
"ai": "5.0.0-alpha.7"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"prettier": "3.5.3",
|
||||
"sst": "3.17.4"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sst/opencode"
|
||||
},
|
||||
"license": "MIT",
|
||||
"prettier": {
|
||||
"semi": false
|
||||
},
|
||||
"overrides": {
|
||||
"zod": "3.24.2"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"esbuild",
|
||||
"protobufjs",
|
||||
"sharp"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@opencode/function",
|
||||
"version": "0.0.1",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "4.20250522.0",
|
||||
"typescript": "catalog:",
|
||||
"@types/node": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { DurableObject } from "cloudflare:workers"
|
||||
import { randomUUID } from "node:crypto"
|
||||
|
||||
type Env = {
|
||||
SYNC_SERVER: DurableObjectNamespace<SyncServer>
|
||||
Bucket: R2Bucket
|
||||
}
|
||||
|
||||
export class SyncServer extends DurableObject<Env> {
|
||||
constructor(ctx: DurableObjectState, env: Env) {
|
||||
super(ctx, env)
|
||||
}
|
||||
async fetch() {
|
||||
console.log("SyncServer subscribe")
|
||||
|
||||
const webSocketPair = new WebSocketPair()
|
||||
const [client, server] = Object.values(webSocketPair)
|
||||
|
||||
this.ctx.acceptWebSocket(server)
|
||||
|
||||
const data = await this.ctx.storage.list()
|
||||
for (const [key, content] of data.entries()) {
|
||||
server.send(JSON.stringify({ key, content }))
|
||||
}
|
||||
|
||||
return new Response(null, {
|
||||
status: 101,
|
||||
webSocket: client,
|
||||
})
|
||||
}
|
||||
|
||||
async webSocketMessage(ws, message) {}
|
||||
|
||||
async webSocketClose(ws, code, reason, wasClean) {
|
||||
ws.close(code, "Durable Object is closing WebSocket")
|
||||
}
|
||||
|
||||
async publish(secret: string, key: string, content: any) {
|
||||
if (secret !== (await this.getSecret())) throw new Error("Invalid secret")
|
||||
const sessionID = await this.getSessionID()
|
||||
if (
|
||||
!key.startsWith(`session/info/${sessionID}`) &&
|
||||
!key.startsWith(`session/message/${sessionID}/`)
|
||||
)
|
||||
return new Response("Error: Invalid key", { status: 400 })
|
||||
|
||||
// store message
|
||||
await this.env.Bucket.put(`share/${key}.json`, JSON.stringify(content), {
|
||||
httpMetadata: {
|
||||
contentType: "application/json",
|
||||
},
|
||||
})
|
||||
await this.ctx.storage.put(key, content)
|
||||
const clients = this.ctx.getWebSockets()
|
||||
console.log("SyncServer publish", key, "to", clients.length, "subscribers")
|
||||
for (const client of clients) {
|
||||
client.send(JSON.stringify({ key, content }))
|
||||
}
|
||||
}
|
||||
|
||||
public async share(sessionID: string) {
|
||||
let secret = await this.getSecret()
|
||||
if (secret) return secret
|
||||
secret = randomUUID()
|
||||
|
||||
await this.ctx.storage.put("secret", secret)
|
||||
await this.ctx.storage.put("sessionID", sessionID)
|
||||
|
||||
return secret
|
||||
}
|
||||
|
||||
public async getData() {
|
||||
const data = await this.ctx.storage.list()
|
||||
const messages = []
|
||||
for (const [key, content] of data.entries()) {
|
||||
messages.push({ key, content })
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
private async getSecret() {
|
||||
return this.ctx.storage.get<string>("secret")
|
||||
}
|
||||
|
||||
private async getSessionID() {
|
||||
return this.ctx.storage.get<string>("sessionID")
|
||||
}
|
||||
|
||||
async clear(secret: string) {
|
||||
await this.assertSecret(secret)
|
||||
await this.ctx.storage.deleteAll()
|
||||
}
|
||||
|
||||
private async assertSecret(secret: string) {
|
||||
if (secret !== (await this.getSecret())) throw new Error("Invalid secret")
|
||||
}
|
||||
|
||||
static shortName(id: string) {
|
||||
return id.substring(id.length - 8)
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
|
||||
const url = new URL(request.url)
|
||||
const splits = url.pathname.split("/")
|
||||
const method = splits[1]
|
||||
|
||||
if (request.method === "GET" && method === "") {
|
||||
return new Response("Hello, world!", {
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
})
|
||||
}
|
||||
|
||||
if (request.method === "POST" && method === "share_create") {
|
||||
const body = await request.json<any>()
|
||||
const sessionID = body.sessionID
|
||||
const short = SyncServer.shortName(sessionID)
|
||||
const id = env.SYNC_SERVER.idFromName(short)
|
||||
const stub = env.SYNC_SERVER.get(id)
|
||||
const secret = await stub.share(sessionID)
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
secret,
|
||||
url: "https://dev.opencode.ai/s/" + short,
|
||||
}),
|
||||
{
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (request.method === "POST" && method === "share_delete") {
|
||||
const body = await request.json<any>()
|
||||
const sessionID = body.sessionID
|
||||
const secret = body.secret
|
||||
const id = env.SYNC_SERVER.idFromName(SyncServer.shortName(sessionID))
|
||||
const stub = env.SYNC_SERVER.get(id)
|
||||
await stub.clear(secret)
|
||||
return new Response(JSON.stringify({}), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
}
|
||||
|
||||
if (request.method === "POST" && method === "share_sync") {
|
||||
const body = await request.json<{
|
||||
sessionID: string
|
||||
secret: string
|
||||
key: string
|
||||
content: any
|
||||
}>()
|
||||
const name = SyncServer.shortName(body.sessionID)
|
||||
const id = env.SYNC_SERVER.idFromName(name)
|
||||
const stub = env.SYNC_SERVER.get(id)
|
||||
await stub.publish(body.secret, body.key, body.content)
|
||||
return new Response(JSON.stringify({}), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
}
|
||||
|
||||
if (request.method === "GET" && method === "share_poll") {
|
||||
const upgradeHeader = request.headers.get("Upgrade")
|
||||
if (!upgradeHeader || upgradeHeader !== "websocket") {
|
||||
return new Response("Error: Upgrade header is required", {
|
||||
status: 426,
|
||||
})
|
||||
}
|
||||
const id = url.searchParams.get("id")
|
||||
console.log("share_poll", id)
|
||||
if (!id)
|
||||
return new Response("Error: Share ID is required", { status: 400 })
|
||||
const stub = env.SYNC_SERVER.get(env.SYNC_SERVER.idFromName(id))
|
||||
return stub.fetch(request)
|
||||
}
|
||||
|
||||
if (request.method === "GET" && method === "share_data") {
|
||||
const id = url.searchParams.get("id")
|
||||
console.log("share_data", id)
|
||||
if (!id)
|
||||
return new Response("Error: Share ID is required", { status: 400 })
|
||||
const stub = env.SYNC_SERVER.get(env.SYNC_SERVER.idFromName(id))
|
||||
const data = await stub.getData()
|
||||
|
||||
let info
|
||||
const messages: Record<string, any> = {}
|
||||
data.forEach((d) => {
|
||||
const [root, type, ...splits] = d.key.split("/")
|
||||
if (root !== "session") return
|
||||
if (type === "info") {
|
||||
info = d.content
|
||||
return
|
||||
}
|
||||
if (type === "message") {
|
||||
const [, messageID] = splits
|
||||
messages[messageID] = d.content
|
||||
}
|
||||
})
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
info,
|
||||
messages,
|
||||
}),
|
||||
{
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
}
|
||||
Vendored
+25
@@ -0,0 +1,25 @@
|
||||
/* 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 {
|
||||
"Web": {
|
||||
"type": "sst.cloudflare.Astro"
|
||||
"url": string
|
||||
}
|
||||
}
|
||||
}
|
||||
// cloudflare
|
||||
import * as cloudflare from "@cloudflare/workers-types";
|
||||
declare module "sst" {
|
||||
export interface Resource {
|
||||
"Api": cloudflare.Service
|
||||
"Bucket": cloudflare.R2Bucket
|
||||
}
|
||||
}
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@tsconfig/node22/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["@cloudflare/workers-types", "node"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
research
|
||||
dist
|
||||
gen
|
||||
app.log
|
||||
@@ -0,0 +1,30 @@
|
||||
# OpenCode Agent Guidelines
|
||||
|
||||
## Build/Test Commands
|
||||
|
||||
- **Install**: `bun install`
|
||||
- **Run**: `bun run index.ts`
|
||||
- **Typecheck**: `bun run typecheck` (npm run typecheck)
|
||||
- **Test**: `bun test` (runs all tests)
|
||||
- **Single test**: `bun test test/tool/tool.test.ts` (specific test file)
|
||||
|
||||
## Code Style
|
||||
|
||||
- **Runtime**: Bun with TypeScript ESM modules
|
||||
- **Imports**: Use relative imports for local modules, named imports preferred
|
||||
- **Types**: Zod schemas for validation, TypeScript interfaces for structure
|
||||
- **Naming**: camelCase for variables/functions, PascalCase for classes/namespaces
|
||||
- **Error handling**: Use Result patterns, avoid throwing exceptions in tools
|
||||
- **File structure**: Namespace-based organization (e.g., `Tool.define()`, `Session.create()`)
|
||||
- DO NOT do unnecessary destructuring of variables
|
||||
- DO NOT use else statements unless necessary
|
||||
- DO NOT use try catch if it can be avoided
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Tools**: Implement `Tool.Info` interface with `execute()` method
|
||||
- **Context**: Pass `sessionID` in tool context, use `App.provide()` for DI
|
||||
- **Validation**: All inputs validated with Zod schemas
|
||||
- **Logging**: Use `Log.create({ service: "name" })` pattern
|
||||
- **Storage**: Use `Storage` namespace for persistence
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# js
|
||||
|
||||
To install dependencies:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
To run:
|
||||
|
||||
```bash
|
||||
bun run index.ts
|
||||
```
|
||||
|
||||
This project was created using `bun init` in bun v1.2.12. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.
|
||||
Executable
+61
@@ -0,0 +1,61 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
if [ -n "$OPENCODE_BIN_PATH" ]; then
|
||||
resolved="$OPENCODE_BIN_PATH"
|
||||
else
|
||||
# Get the real path of this script, resolving any symlinks
|
||||
script_path="$0"
|
||||
while [ -L "$script_path" ]; do
|
||||
link_target="$(readlink "$script_path")"
|
||||
case "$link_target" in
|
||||
/*) script_path="$link_target" ;;
|
||||
*) script_path="$(dirname "$script_path")/$link_target" ;;
|
||||
esac
|
||||
done
|
||||
script_dir="$(dirname "$script_path")"
|
||||
script_dir="$(cd "$script_dir" && pwd)"
|
||||
|
||||
# Map platform names
|
||||
case "$(uname -s)" in
|
||||
Darwin) platform="darwin" ;;
|
||||
Linux) platform="linux" ;;
|
||||
MINGW*|CYGWIN*|MSYS*) platform="win32" ;;
|
||||
*) platform="$(uname -s | tr '[:upper:]' '[:lower:]')" ;;
|
||||
esac
|
||||
|
||||
# Map architecture names
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64) arch="x64" ;;
|
||||
aarch64) arch="arm64" ;;
|
||||
armv7l) arch="arm" ;;
|
||||
*) arch="$(uname -m)" ;;
|
||||
esac
|
||||
|
||||
name="opencode-${platform}-${arch}"
|
||||
binary="opencode"
|
||||
[ "$platform" = "win32" ] && binary="opencode.exe"
|
||||
|
||||
# Search for the binary starting from real script location
|
||||
resolved=""
|
||||
current_dir="$script_dir"
|
||||
while [ "$current_dir" != "/" ]; do
|
||||
candidate="$current_dir/node_modules/$name/bin/$binary"
|
||||
if [ -f "$candidate" ]; then
|
||||
resolved="$candidate"
|
||||
break
|
||||
fi
|
||||
current_dir="$(dirname "$current_dir")"
|
||||
done
|
||||
|
||||
if [ -z "$resolved" ]; then
|
||||
printf "It seems that your package manager failed to install the right version of the SST CLI for your platform. You can try manually installing the \"%s\" package\n" "$name" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Handle SIGINT gracefully
|
||||
trap '' INT
|
||||
|
||||
# Execute the binary with all arguments
|
||||
exec "$resolved" "$@"
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "0.0.0",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"exports": {
|
||||
"./*": [
|
||||
"./src/*.ts",
|
||||
"./src/*/index.ts"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "1.0.7",
|
||||
"@types/bun": "latest",
|
||||
"@types/turndown": "5.0.5",
|
||||
"@types/yargs": "17.0.33",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "0.11.0",
|
||||
"@flystorage/file-storage": "1.1.0",
|
||||
"@flystorage/local-fs": "1.1.0",
|
||||
"@hono/zod-validator": "0.5.0",
|
||||
"@openauthjs/openauth": "0.4.3",
|
||||
"@standard-schema/spec": "1.0.0",
|
||||
"ai": "catalog:",
|
||||
"decimal.js": "10.5.0",
|
||||
"diff": "8.0.2",
|
||||
"env-paths": "3.0.0",
|
||||
"hono": "4.7.10",
|
||||
"hono-openapi": "0.4.8",
|
||||
"open": "10.1.2",
|
||||
"remeda": "2.22.3",
|
||||
"ts-lsp-client": "1.0.3",
|
||||
"turndown": "7.2.0",
|
||||
"vscode-jsonrpc": "8.2.1",
|
||||
"vscode-languageclient": "8",
|
||||
"xdg-basedir": "5.1.0",
|
||||
"yargs": "18.0.0",
|
||||
"zod": "catalog:",
|
||||
"zod-openapi": "4.2.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
# Maintainer: dax
|
||||
# Maintainer: adam
|
||||
|
||||
pkgname='opencode-bin'
|
||||
pkgver={{VERSION}}
|
||||
pkgrel=1
|
||||
pkgdesc='The AI coding agent built for the terminal.'
|
||||
url='https://github.com/sst/opencode'
|
||||
arch=('aarch64' 'x86_64')
|
||||
license=('MIT')
|
||||
provides=('opencode')
|
||||
conflicts=('opencode')
|
||||
depends=('fzf' 'ripgrep')
|
||||
|
||||
source_aarch64=("${pkgname}_${pkgver}_aarch64.zip::{{ARM64_URL}}")
|
||||
sha256sums_aarch64=('{{ARM64_SHA}}')
|
||||
|
||||
source_x86_64=("${pkgname}_${pkgver}_x86_64.zip::{{X64_URL}}")
|
||||
sha256sums_x86_64=('{{X64_SHA}}')
|
||||
|
||||
package() {
|
||||
install -Dm755 ./opencode "${pkgdir}/usr/bin/opencode"
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import { fileURLToPath } from "url"
|
||||
import { createRequire } from "module"
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
function detectPlatformAndArch() {
|
||||
// Map platform names
|
||||
let platform
|
||||
switch (os.platform()) {
|
||||
case "darwin":
|
||||
platform = "darwin"
|
||||
break
|
||||
case "linux":
|
||||
platform = "linux"
|
||||
break
|
||||
case "win32":
|
||||
platform = "win32"
|
||||
break
|
||||
default:
|
||||
platform = os.platform()
|
||||
break
|
||||
}
|
||||
|
||||
// Map architecture names
|
||||
let arch
|
||||
switch (os.arch()) {
|
||||
case "x64":
|
||||
arch = "x64"
|
||||
break
|
||||
case "arm64":
|
||||
arch = "arm64"
|
||||
break
|
||||
case "arm":
|
||||
arch = "arm"
|
||||
break
|
||||
default:
|
||||
arch = os.arch()
|
||||
break
|
||||
}
|
||||
|
||||
return { platform, arch }
|
||||
}
|
||||
|
||||
function findBinary() {
|
||||
const { platform, arch } = detectPlatformAndArch()
|
||||
const packageName = `opencode-${platform}-${arch}`
|
||||
const binary = platform === "win32" ? "opencode.exe" : "opencode"
|
||||
|
||||
try {
|
||||
// Use require.resolve to find the package
|
||||
const packageJsonPath = require.resolve(`${packageName}/package.json`)
|
||||
const packageDir = path.dirname(packageJsonPath)
|
||||
const binaryPath = path.join(packageDir, "bin", binary)
|
||||
|
||||
if (!fs.existsSync(binaryPath)) {
|
||||
throw new Error(`Binary not found at ${binaryPath}`)
|
||||
}
|
||||
|
||||
return binaryPath
|
||||
} catch (error) {
|
||||
throw new Error(`Could not find package ${packageName}: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
try {
|
||||
const binaryPath = findBinary()
|
||||
const binScript = path.join(__dirname, "bin", "opencode")
|
||||
|
||||
// Remove existing bin script if it exists
|
||||
if (fs.existsSync(binScript)) {
|
||||
fs.unlinkSync(binScript)
|
||||
}
|
||||
|
||||
// Create symlink to the actual binary
|
||||
fs.symlinkSync(binaryPath, binScript)
|
||||
console.log(`OpenCode binary symlinked: ${binScript} -> ${binaryPath}`)
|
||||
} catch (error) {
|
||||
console.error("Failed to create OpenCode binary symlink:", error.message)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
Executable
+161
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { $ } from "bun"
|
||||
|
||||
import pkg from "../package.json"
|
||||
|
||||
const dry = process.argv.includes("--dry")
|
||||
const snapshot = process.argv.includes("--snapshot")
|
||||
|
||||
const version = snapshot
|
||||
? `0.0.0-${new Date().toISOString().slice(0, 16).replace(/[-:T]/g, "")}`
|
||||
: await $`git describe --tags --exact-match HEAD`
|
||||
.text()
|
||||
.then((x) => x.substring(1).trim())
|
||||
.catch(() => {
|
||||
console.error("tag not found")
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
console.log(`publishing ${version}`)
|
||||
|
||||
const GOARCH: Record<string, string> = {
|
||||
arm64: "arm64",
|
||||
x64: "amd64",
|
||||
}
|
||||
|
||||
const targets = [
|
||||
["linux", "arm64"],
|
||||
["linux", "x64"],
|
||||
["darwin", "x64"],
|
||||
["darwin", "arm64"],
|
||||
["windows", "x64"],
|
||||
]
|
||||
|
||||
await $`rm -rf dist`
|
||||
|
||||
const optionalDependencies: Record<string, string> = {}
|
||||
const npmTag = snapshot ? "snapshot" : "latest"
|
||||
for (const [os, arch] of targets) {
|
||||
console.log(`building ${os}-${arch}`)
|
||||
const name = `${pkg.name}-${os}-${arch}`
|
||||
await $`mkdir -p dist/${name}/bin`
|
||||
await $`GOOS=${os} GOARCH=${GOARCH[arch]} go build -ldflags="-s -w -X main.Version=${version}" -o ../opencode/dist/${name}/bin/tui ../tui/cmd/opencode/main.go`.cwd(
|
||||
"../tui",
|
||||
)
|
||||
await $`bun build --define OPENCODE_VERSION="'${version}'" --compile --minify --target=bun-${os}-${arch} --outfile=dist/${name}/bin/opencode ./src/index.ts ./dist/${name}/bin/tui`
|
||||
await $`rm -rf ./dist/${name}/bin/tui`
|
||||
await Bun.file(`dist/${name}/package.json`).write(
|
||||
JSON.stringify(
|
||||
{
|
||||
name,
|
||||
version,
|
||||
os: [os === "windows" ? "win32" : os],
|
||||
cpu: [arch],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
if (!dry)
|
||||
await $`cd dist/${name} && bun publish --access public --tag ${npmTag}`
|
||||
optionalDependencies[name] = version
|
||||
}
|
||||
|
||||
await $`mkdir -p ./dist/${pkg.name}`
|
||||
await $`cp -r ./bin ./dist/${pkg.name}/bin`
|
||||
await $`cp ./script/postinstall.js ./dist/${pkg.name}/postinstall.js`
|
||||
await Bun.file(`./dist/${pkg.name}/package.json`).write(
|
||||
JSON.stringify(
|
||||
{
|
||||
name: pkg.name + "-ai",
|
||||
bin: {
|
||||
[pkg.name]: `./bin/${pkg.name}`,
|
||||
},
|
||||
scripts: {
|
||||
postinstall: "node ./postinstall.js",
|
||||
},
|
||||
version,
|
||||
optionalDependencies,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
if (!dry)
|
||||
await $`cd ./dist/${pkg.name} && bun publish --access public --tag ${npmTag}`
|
||||
|
||||
if (!snapshot) {
|
||||
// Github Release
|
||||
for (const key of Object.keys(optionalDependencies)) {
|
||||
await $`cd dist/${key}/bin && zip -r ../../${key}.zip *`
|
||||
}
|
||||
|
||||
const previous = await fetch(
|
||||
"https://api.github.com/repos/sst/opencode/releases/latest",
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.then((data) => data.tag_name)
|
||||
|
||||
const commits = await fetch(
|
||||
`https://api.github.com/repos/sst/opencode/compare/${previous}...HEAD`,
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.then((data) => data.commits || [])
|
||||
|
||||
const notes = commits
|
||||
.map((commit: any) => `- ${commit.commit.message.split("\n")[0]}`)
|
||||
.filter((x: string) => {
|
||||
const lower = x.toLowerCase()
|
||||
return (
|
||||
!lower.includes("chore:") &&
|
||||
!lower.includes("ci:") &&
|
||||
!lower.includes("docs:")
|
||||
)
|
||||
})
|
||||
.join("\n")
|
||||
|
||||
if (!dry)
|
||||
await $`gh release create v${version} --title "v${version}" --notes ${notes} ./dist/*.zip`
|
||||
|
||||
// AUR package
|
||||
const pkgbuildTemplate = await Bun.file("./script/PKGBUILD.template").text()
|
||||
const pkgbuild = pkgbuildTemplate
|
||||
.replace("{{VERSION}}", version.split("-")[0])
|
||||
.replace(
|
||||
"{{ARM64_URL}}",
|
||||
`https://github.com/sst/opencode/releases/download/v${version}/opencode-linux-arm64.zip`,
|
||||
)
|
||||
.replace(
|
||||
"{{ARM64_SHA}}",
|
||||
await $`sha256sum ./dist/opencode-linux-arm64.zip | cut -d' ' -f1`
|
||||
.text()
|
||||
.then((x) => x.trim()),
|
||||
)
|
||||
.replace(
|
||||
"{{X64_URL}}",
|
||||
`https://github.com/sst/opencode/releases/download/v${version}/opencode-linux-x64.zip`,
|
||||
)
|
||||
.replace(
|
||||
"{{X64_SHA}}",
|
||||
await $`sha256sum ./dist/opencode-linux-x64.zip | cut -d' ' -f1`
|
||||
.text()
|
||||
.then((x) => x.trim()),
|
||||
)
|
||||
|
||||
await $`rm -rf ./dist/aur-opencode-bin`
|
||||
const gitEnv: Record<string, string> = process.env["AUR_KEY"]
|
||||
? { GIT_SSH_COMMAND: `ssh -i "${process.env["AUR_KEY"].trim()}"` }
|
||||
: {}
|
||||
|
||||
await $`git clone ssh://aur@aur.archlinux.org/opencode-bin.git ./dist/aur-opencode-bin`.env(
|
||||
gitEnv,
|
||||
)
|
||||
await Bun.file("./dist/aur-opencode-bin/PKGBUILD").write(pkgbuild)
|
||||
await $`cd ./dist/aur-opencode-bin && makepkg --printsrcinfo > .SRCINFO`
|
||||
await $`cd ./dist/aur-opencode-bin && git add PKGBUILD .SRCINFO`.env(gitEnv)
|
||||
await $`cd ./dist/aur-opencode-bin && git commit -m "Update to v${version}"`.env(
|
||||
gitEnv,
|
||||
)
|
||||
if (!dry) await $`cd ./dist/aur-opencode-bin && git push`.env(gitEnv)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { Log } from "../util/log"
|
||||
import { Context } from "../util/context"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Global } from "../global"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import { z } from "zod"
|
||||
|
||||
export namespace App {
|
||||
const log = Log.create({ service: "app" })
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
user: z.string(),
|
||||
git: z.boolean(),
|
||||
path: z.object({
|
||||
config: z.string(),
|
||||
data: z.string(),
|
||||
root: z.string(),
|
||||
cwd: z.string(),
|
||||
}),
|
||||
time: z.object({
|
||||
initialized: z.number().optional(),
|
||||
}),
|
||||
})
|
||||
.openapi({
|
||||
ref: "App.Info",
|
||||
})
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
const ctx = Context.create<Awaited<ReturnType<typeof create>>>("app")
|
||||
|
||||
const APP_JSON = "app.json"
|
||||
|
||||
async function create(input: { cwd: string; version: string }) {
|
||||
log.info("creating", {
|
||||
cwd: input.cwd,
|
||||
})
|
||||
const git = await Filesystem.findUp(".git", input.cwd).then(([x]) =>
|
||||
x ? path.dirname(x) : undefined,
|
||||
)
|
||||
log.info("git", { git })
|
||||
|
||||
const data = path.join(
|
||||
Global.Path.data,
|
||||
"project",
|
||||
git ? git.split(path.sep).join("-") : "global",
|
||||
)
|
||||
const stateFile = Bun.file(path.join(data, APP_JSON))
|
||||
const state = (await stateFile.json().catch(() => ({}))) as {
|
||||
initialized: number
|
||||
version: string
|
||||
}
|
||||
state.version = input.version
|
||||
await stateFile.write(JSON.stringify(state))
|
||||
|
||||
const services = new Map<
|
||||
any,
|
||||
{
|
||||
state: any
|
||||
shutdown?: (input: any) => Promise<void>
|
||||
}
|
||||
>()
|
||||
|
||||
const info: Info = {
|
||||
user: os.userInfo().username,
|
||||
time: {
|
||||
initialized: state.initialized,
|
||||
},
|
||||
git: git !== undefined,
|
||||
path: {
|
||||
config: Global.Path.config,
|
||||
data,
|
||||
root: git ?? input.cwd,
|
||||
cwd: input.cwd,
|
||||
},
|
||||
}
|
||||
const result = {
|
||||
version: input.version,
|
||||
services,
|
||||
info,
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function state<State>(
|
||||
key: any,
|
||||
init: (app: Info) => State,
|
||||
shutdown?: (state: Awaited<State>) => Promise<void>,
|
||||
) {
|
||||
return () => {
|
||||
const app = ctx.use()
|
||||
const services = app.services
|
||||
if (!services.has(key)) {
|
||||
log.info("registering service", { name: key })
|
||||
services.set(key, {
|
||||
state: init(app.info),
|
||||
shutdown,
|
||||
})
|
||||
}
|
||||
return services.get(key)?.state as State
|
||||
}
|
||||
}
|
||||
|
||||
export function info() {
|
||||
return ctx.use().info
|
||||
}
|
||||
|
||||
export async function provide<T>(
|
||||
input: { cwd: string; version: string },
|
||||
cb: (app: Info) => Promise<T>,
|
||||
) {
|
||||
const app = await create(input)
|
||||
return ctx.provide(app, async () => {
|
||||
const result = await cb(app.info)
|
||||
for (const [key, entry] of app.services.entries()) {
|
||||
if (!entry.shutdown) continue
|
||||
log.info("shutdown", { name: key })
|
||||
await entry.shutdown?.(await entry.state)
|
||||
}
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
export async function initialize() {
|
||||
const { info, version } = ctx.use()
|
||||
info.time.initialized = Date.now()
|
||||
await Bun.write(
|
||||
path.join(info.path.data, APP_JSON),
|
||||
JSON.stringify({
|
||||
version,
|
||||
initialized: Date.now(),
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { generatePKCE } from "@openauthjs/openauth/pkce"
|
||||
import fs from "fs/promises"
|
||||
import { Auth } from "./index"
|
||||
|
||||
export namespace AuthAnthropic {
|
||||
const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
||||
|
||||
export async function authorize() {
|
||||
const pkce = await generatePKCE()
|
||||
const url = new URL("https://claude.ai/oauth/authorize", import.meta.url)
|
||||
url.searchParams.set("code", "true")
|
||||
url.searchParams.set("client_id", "9d1c250a-e61b-44d9-88ed-5944d1962f5e")
|
||||
url.searchParams.set("response_type", "code")
|
||||
url.searchParams.set(
|
||||
"redirect_uri",
|
||||
"https://console.anthropic.com/oauth/code/callback",
|
||||
)
|
||||
url.searchParams.set(
|
||||
"scope",
|
||||
"org:create_api_key user:profile user:inference",
|
||||
)
|
||||
url.searchParams.set("code_challenge", pkce.challenge)
|
||||
url.searchParams.set("code_challenge_method", "S256")
|
||||
url.searchParams.set("state", pkce.verifier)
|
||||
return {
|
||||
url: url.toString(),
|
||||
verifier: pkce.verifier,
|
||||
}
|
||||
}
|
||||
|
||||
export async function exchange(code: string, verifier: string) {
|
||||
const splits = code.split("#")
|
||||
const result = await fetch("https://console.anthropic.com/v1/oauth/token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
code: splits[0],
|
||||
state: splits[1],
|
||||
grant_type: "authorization_code",
|
||||
client_id: "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
|
||||
redirect_uri: "https://console.anthropic.com/oauth/code/callback",
|
||||
code_verifier: verifier,
|
||||
}),
|
||||
})
|
||||
if (!result.ok) throw new ExchangeFailed()
|
||||
const json = await result.json()
|
||||
await Auth.set("anthropic", {
|
||||
type: "oauth",
|
||||
refresh: json.refresh_token as string,
|
||||
expires: Date.now() + json.expires_in * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
export async function access() {
|
||||
const info = await Auth.get("anthropic")
|
||||
if (!info || info.type !== "oauth") return
|
||||
const response = await fetch(
|
||||
"https://console.anthropic.com/v1/oauth/token",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: info.refresh,
|
||||
client_id: CLIENT_ID,
|
||||
}),
|
||||
},
|
||||
)
|
||||
if (!response.ok) return
|
||||
const json = await response.json()
|
||||
await Auth.set("anthropic", {
|
||||
type: "oauth",
|
||||
refresh: json.refresh_token as string,
|
||||
expires: Date.now() + json.expires_in * 1000,
|
||||
})
|
||||
return json.access_token as string
|
||||
}
|
||||
|
||||
export class ExchangeFailed extends Error {
|
||||
constructor() {
|
||||
super("Exchange failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import path from "path"
|
||||
import { Global } from "../global"
|
||||
import fs from "fs/promises"
|
||||
import { z } from "zod"
|
||||
|
||||
export namespace Auth {
|
||||
export const Oauth = z.object({
|
||||
type: z.literal("oauth"),
|
||||
refresh: z.string(),
|
||||
expires: z.number(),
|
||||
})
|
||||
|
||||
export const Api = z.object({
|
||||
type: z.literal("api"),
|
||||
key: z.string(),
|
||||
})
|
||||
|
||||
export const Info = z.discriminatedUnion("type", [Oauth, Api])
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
const filepath = path.join(Global.Path.data, "auth.json")
|
||||
|
||||
export async function get(providerID: string) {
|
||||
const file = Bun.file(filepath)
|
||||
return file
|
||||
.json()
|
||||
.catch(() => ({}))
|
||||
.then((x) => x[providerID] as Info | undefined)
|
||||
}
|
||||
|
||||
export async function all(): Promise<Record<string, Info>> {
|
||||
const file = Bun.file(filepath)
|
||||
return file.json().catch(() => ({}))
|
||||
}
|
||||
|
||||
export async function set(key: string, info: Info) {
|
||||
const file = Bun.file(filepath)
|
||||
const data = await all()
|
||||
await Bun.write(file, JSON.stringify({ ...data, [key]: info }, null, 2))
|
||||
await fs.chmod(file.name!, 0o600)
|
||||
}
|
||||
|
||||
export async function remove(key: string) {
|
||||
const file = Bun.file(filepath)
|
||||
const data = await all()
|
||||
delete data[key]
|
||||
await Bun.write(file, JSON.stringify(data, null, 2))
|
||||
await fs.chmod(file.name!, 0o600)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Log } from "../util/log"
|
||||
export namespace BunProc {
|
||||
const log = Log.create({ service: "bun" })
|
||||
|
||||
export async function run(
|
||||
cmd: string[],
|
||||
options?: Bun.SpawnOptions.OptionsObject<any, any, any>,
|
||||
) {
|
||||
log.info("running", {
|
||||
cmd: [which(), ...cmd],
|
||||
options,
|
||||
})
|
||||
const result = Bun.spawn([which(), ...cmd], {
|
||||
...options,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
env: {
|
||||
...process.env,
|
||||
...options?.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
})
|
||||
const code = await result.exited
|
||||
if (code !== 0) {
|
||||
throw new Error(`Command failed with exit code ${result.exitCode}`)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function which() {
|
||||
return process.execPath
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { z, type ZodType } from "zod"
|
||||
import { App } from "../app/app"
|
||||
import { Log } from "../util/log"
|
||||
|
||||
export namespace Bus {
|
||||
const log = Log.create({ service: "bus" })
|
||||
type Subscription = (event: any) => void
|
||||
|
||||
const state = App.state("bus", () => {
|
||||
const subscriptions = new Map<any, Subscription[]>()
|
||||
|
||||
return {
|
||||
subscriptions,
|
||||
}
|
||||
})
|
||||
|
||||
export type EventDefinition = ReturnType<typeof event>
|
||||
|
||||
const registry = new Map<string, EventDefinition>()
|
||||
|
||||
export function event<Type extends string, Properties extends ZodType>(
|
||||
type: Type,
|
||||
properties: Properties,
|
||||
) {
|
||||
const result = {
|
||||
type,
|
||||
properties,
|
||||
}
|
||||
registry.set(type, result)
|
||||
return result
|
||||
}
|
||||
|
||||
export function payloads() {
|
||||
return z.discriminatedUnion(
|
||||
"type",
|
||||
registry
|
||||
.entries()
|
||||
.map(([type, def]) =>
|
||||
z
|
||||
.object({
|
||||
type: z.literal(type),
|
||||
properties: def.properties,
|
||||
})
|
||||
.openapi({
|
||||
ref: "Event" + "." + def.type,
|
||||
}),
|
||||
)
|
||||
.toArray() as any,
|
||||
)
|
||||
}
|
||||
|
||||
export function publish<Definition extends EventDefinition>(
|
||||
def: Definition,
|
||||
properties: z.output<Definition["properties"]>,
|
||||
) {
|
||||
const payload = {
|
||||
type: def.type,
|
||||
properties,
|
||||
}
|
||||
log.info("publishing", {
|
||||
type: def.type,
|
||||
})
|
||||
for (const key of [def.type, "*"]) {
|
||||
const match = state().subscriptions.get(key)
|
||||
for (const sub of match ?? []) {
|
||||
sub(payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribe<Definition extends EventDefinition>(
|
||||
def: Definition,
|
||||
callback: (event: {
|
||||
type: Definition["type"]
|
||||
properties: z.infer<Definition["properties"]>
|
||||
}) => void,
|
||||
) {
|
||||
return raw(def.type, callback)
|
||||
}
|
||||
|
||||
export function once<Definition extends EventDefinition>(
|
||||
def: Definition,
|
||||
callback: (event: {
|
||||
type: Definition["type"]
|
||||
properties: z.infer<Definition["properties"]>
|
||||
}) => "done" | undefined,
|
||||
) {
|
||||
const unsub = subscribe(def, (event) => {
|
||||
if (callback(event)) unsub()
|
||||
})
|
||||
}
|
||||
|
||||
export function subscribeAll(callback: (event: any) => void) {
|
||||
return raw("*", callback)
|
||||
}
|
||||
|
||||
function raw(type: string, callback: (event: any) => void) {
|
||||
log.info("subscribing", { type })
|
||||
const subscriptions = state().subscriptions
|
||||
let match = subscriptions.get(type) ?? []
|
||||
match.push(callback)
|
||||
subscriptions.set(type, match)
|
||||
|
||||
return () => {
|
||||
log.info("unsubscribing", { type })
|
||||
const match = subscriptions.get(type)
|
||||
if (!match) return
|
||||
const index = match.indexOf(callback)
|
||||
if (index === -1) return
|
||||
match.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { AuthAnthropic } from "../../auth/anthropic"
|
||||
import { Auth } from "../../auth"
|
||||
import { cmd } from "./cmd"
|
||||
import * as prompts from "@clack/prompts"
|
||||
import open from "open"
|
||||
import { UI } from "../ui"
|
||||
import { ModelsDev } from "../../provider/models"
|
||||
|
||||
export const AuthCommand = cmd({
|
||||
command: "auth",
|
||||
describe: "manage credentials",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.command(AuthLoginCommand)
|
||||
.command(AuthLogoutCommand)
|
||||
.command(AuthListCommand)
|
||||
.demandCommand(),
|
||||
async handler(args) {},
|
||||
})
|
||||
|
||||
export const AuthListCommand = cmd({
|
||||
command: "list",
|
||||
aliases: ["ls"],
|
||||
describe: "list providers",
|
||||
async handler() {
|
||||
UI.empty()
|
||||
prompts.intro("Credentials")
|
||||
const results = await Auth.all().then((x) => Object.entries(x))
|
||||
const database = await ModelsDev.get()
|
||||
|
||||
for (const [providerID, result] of results) {
|
||||
const name = database[providerID]?.name || providerID
|
||||
prompts.log.info(`${name} ${UI.Style.TEXT_DIM}(${result.type})`)
|
||||
}
|
||||
|
||||
prompts.outro(`${results.length} credentials`)
|
||||
},
|
||||
})
|
||||
|
||||
export const AuthLoginCommand = cmd({
|
||||
command: "login",
|
||||
describe: "login to a provider",
|
||||
async handler() {
|
||||
UI.empty()
|
||||
prompts.intro("Add credential")
|
||||
const provider = await prompts.select({
|
||||
message: "Select provider",
|
||||
maxItems: 2,
|
||||
options: [
|
||||
{
|
||||
label: "Anthropic",
|
||||
value: "anthropic",
|
||||
},
|
||||
{
|
||||
label: "OpenAI",
|
||||
value: "openai",
|
||||
},
|
||||
{
|
||||
label: "Google",
|
||||
value: "google",
|
||||
},
|
||||
],
|
||||
})
|
||||
if (prompts.isCancel(provider)) throw new UI.CancelledError()
|
||||
|
||||
if (provider === "anthropic") {
|
||||
const method = await prompts.select({
|
||||
message: "Login method",
|
||||
options: [
|
||||
{
|
||||
label: "Claude Pro/Max",
|
||||
value: "oauth",
|
||||
},
|
||||
{
|
||||
label: "API Key",
|
||||
value: "api",
|
||||
},
|
||||
],
|
||||
})
|
||||
if (prompts.isCancel(method)) throw new UI.CancelledError()
|
||||
|
||||
if (method === "oauth") {
|
||||
// some weird bug where program exits without this
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
const { url, verifier } = await AuthAnthropic.authorize()
|
||||
prompts.note("Opening browser...")
|
||||
await open(url)
|
||||
prompts.log.info(url)
|
||||
|
||||
const code = await prompts.text({
|
||||
message: "Paste the authorization code here: ",
|
||||
validate: (x) => (x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(code)) throw new UI.CancelledError()
|
||||
|
||||
await AuthAnthropic.exchange(code, verifier)
|
||||
.then(() => {
|
||||
prompts.log.success("Login successful")
|
||||
})
|
||||
.catch(() => {
|
||||
prompts.log.error("Invalid code")
|
||||
})
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const key = await prompts.password({
|
||||
message: "Enter your API key",
|
||||
validate: (x) => (x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(key)) throw new UI.CancelledError()
|
||||
await Auth.set(provider, {
|
||||
type: "api",
|
||||
key,
|
||||
})
|
||||
|
||||
prompts.outro("Done")
|
||||
},
|
||||
})
|
||||
|
||||
export const AuthLogoutCommand = cmd({
|
||||
command: "logout",
|
||||
describe: "logout from a configured provider",
|
||||
async handler() {
|
||||
UI.empty()
|
||||
const credentials = await Auth.all().then((x) => Object.entries(x))
|
||||
prompts.intro("Remove credential")
|
||||
if (credentials.length === 0) {
|
||||
prompts.log.error("No credentials found")
|
||||
return
|
||||
}
|
||||
const database = await ModelsDev.get()
|
||||
const providerID = await prompts.select({
|
||||
message: "Select provider",
|
||||
options: credentials.map(([key, value]) => ({
|
||||
label:
|
||||
(database[key]?.name || key) +
|
||||
UI.Style.TEXT_DIM +
|
||||
" (" +
|
||||
value.type +
|
||||
")",
|
||||
value: key,
|
||||
})),
|
||||
})
|
||||
if (prompts.isCancel(providerID)) throw new UI.CancelledError()
|
||||
await Auth.remove(providerID)
|
||||
prompts.outro("Logout successful")
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { CommandModule } from "yargs"
|
||||
|
||||
export function cmd<T, U>(input: CommandModule<T, U>) {
|
||||
return input
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Server } from "../../server/server"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import type { CommandModule } from "yargs"
|
||||
|
||||
export const GenerateCommand = {
|
||||
command: "generate",
|
||||
handler: async () => {
|
||||
const specs = await Server.openapi()
|
||||
const dir = "gen"
|
||||
await fs.rmdir(dir, { recursive: true }).catch(() => {})
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
await Bun.write(
|
||||
path.join(dir, "openapi.json"),
|
||||
JSON.stringify(specs, null, 2),
|
||||
)
|
||||
},
|
||||
} satisfies CommandModule
|
||||
@@ -0,0 +1,20 @@
|
||||
import { AuthAnthropic } from "../../auth/anthropic"
|
||||
import { UI } from "../ui"
|
||||
|
||||
// Example: https://claude.ai/oauth/authorize?code=true&client_id=9d1c250a-e61b-44d9-88ed-5944d1962f5e&response_type=code&redirect_uri=https%3A%2F%2Fconsole.anthropic.com%2Foauth%2Fcode%2Fcallback&scope=org%3Acreate_api_key+user%3Aprofile+user%3Ainference&code_challenge=MdFtFgFap23AWDSN0oa3-eaKjQRFE4CaEhXx8M9fHZg&code_challenge_method=S256&state=rKLtaDzm88GSwekyEqdi0wXX-YqIr13tSzYymSzpvfs
|
||||
|
||||
export const LoginAnthropicCommand = {
|
||||
command: "anthropic",
|
||||
describe: "Login to Anthropic",
|
||||
handler: async () => {
|
||||
const { url, verifier } = await AuthAnthropic.authorize()
|
||||
|
||||
UI.println("Login to Anthropic")
|
||||
UI.println("Open the following URL in your browser:")
|
||||
UI.println(url)
|
||||
UI.println("")
|
||||
|
||||
const code = await UI.input("Paste the authorization code here: ")
|
||||
await AuthAnthropic.exchange(code, verifier)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { Argv } from "yargs"
|
||||
import { App } from "../../app/app"
|
||||
import { Bus } from "../../bus"
|
||||
import { Provider } from "../../provider/provider"
|
||||
import { Session } from "../../session"
|
||||
import { Share } from "../../share/share"
|
||||
import { Message } from "../../session/message"
|
||||
import { UI } from "../ui"
|
||||
import { VERSION } from "../version"
|
||||
|
||||
const COLOR = [
|
||||
UI.Style.TEXT_SUCCESS_BOLD,
|
||||
UI.Style.TEXT_INFO_BOLD,
|
||||
UI.Style.TEXT_HIGHLIGHT_BOLD,
|
||||
UI.Style.TEXT_WARNING_BOLD,
|
||||
]
|
||||
|
||||
const TOOL: Record<string, [string, string]> = {
|
||||
opencode_todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
|
||||
opencode_todoread: ["Todo", UI.Style.TEXT_WARNING_BOLD],
|
||||
opencode_bash: ["Bash", UI.Style.TEXT_DANGER_BOLD],
|
||||
opencode_edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD],
|
||||
opencode_glob: ["Glob", UI.Style.TEXT_INFO_BOLD],
|
||||
opencode_grep: ["Grep", UI.Style.TEXT_INFO_BOLD],
|
||||
opencode_list: ["List", UI.Style.TEXT_INFO_BOLD],
|
||||
opencode_read: ["Read", UI.Style.TEXT_HIGHLIGHT_BOLD],
|
||||
opencode_write: ["Write", UI.Style.TEXT_SUCCESS_BOLD],
|
||||
}
|
||||
|
||||
export const RunCommand = {
|
||||
command: "run [message..]",
|
||||
describe: "Run OpenCode with a message",
|
||||
builder: (yargs: Argv) => {
|
||||
return yargs
|
||||
.positional("message", {
|
||||
describe: "Message to send",
|
||||
type: "string",
|
||||
array: true,
|
||||
default: [],
|
||||
})
|
||||
.option("session", {
|
||||
describe: "Session ID to continue",
|
||||
type: "string",
|
||||
})
|
||||
},
|
||||
handler: async (args: {
|
||||
message: string[]
|
||||
session?: string
|
||||
printLogs?: boolean
|
||||
}) => {
|
||||
const message = args.message.join(" ")
|
||||
await App.provide(
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
version: VERSION,
|
||||
},
|
||||
async () => {
|
||||
await Share.init()
|
||||
const session = args.session
|
||||
? await Session.get(args.session)
|
||||
: await Session.create()
|
||||
|
||||
UI.println(UI.Style.TEXT_HIGHLIGHT_BOLD + "◍ OpenCode", VERSION)
|
||||
UI.empty()
|
||||
UI.println(UI.Style.TEXT_NORMAL_BOLD + "> ", message)
|
||||
UI.empty()
|
||||
UI.println(
|
||||
UI.Style.TEXT_INFO_BOLD +
|
||||
"~ https://dev.opencode.ai/s/" +
|
||||
session.id.slice(-8),
|
||||
)
|
||||
UI.empty()
|
||||
|
||||
function printEvent(color: string, type: string, title: string) {
|
||||
UI.println(
|
||||
color + `|`,
|
||||
UI.Style.TEXT_NORMAL +
|
||||
UI.Style.TEXT_DIM +
|
||||
` ${type.padEnd(7, " ")}`,
|
||||
"",
|
||||
UI.Style.TEXT_NORMAL + title,
|
||||
)
|
||||
}
|
||||
|
||||
Bus.subscribe(Message.Event.PartUpdated, async (evt) => {
|
||||
const part = evt.properties.part
|
||||
const message = await Session.getMessage(
|
||||
evt.properties.sessionID,
|
||||
evt.properties.messageID,
|
||||
)
|
||||
|
||||
if (
|
||||
part.type === "tool-invocation" &&
|
||||
part.toolInvocation.state === "result"
|
||||
) {
|
||||
const metadata =
|
||||
message.metadata.tool[part.toolInvocation.toolCallId]
|
||||
const [tool, color] = TOOL[part.toolInvocation.toolName] ?? [
|
||||
part.toolInvocation.toolName,
|
||||
UI.Style.TEXT_INFO_BOLD,
|
||||
]
|
||||
printEvent(color, tool, metadata.title)
|
||||
}
|
||||
|
||||
if (part.type === "text") {
|
||||
if (part.text.includes("\n")) {
|
||||
UI.empty()
|
||||
UI.println(part.text)
|
||||
UI.empty()
|
||||
return
|
||||
}
|
||||
printEvent(UI.Style.TEXT_NORMAL_BOLD, "Text", part.text)
|
||||
}
|
||||
})
|
||||
|
||||
const { providerID, modelID } = await Provider.defaultModel()
|
||||
await Session.chat({
|
||||
sessionID: session.id,
|
||||
providerID,
|
||||
modelID,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: message,
|
||||
},
|
||||
],
|
||||
})
|
||||
UI.empty()
|
||||
},
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { App } from "../../app/app"
|
||||
import { VERSION } from "../version"
|
||||
import { cmd } from "./cmd"
|
||||
|
||||
export const ScrapCommand = cmd({
|
||||
command: "scrap <file>",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("file", { type: "string", demandOption: true }),
|
||||
async handler() {
|
||||
await App.provide({ cwd: process.cwd(), version: VERSION }, async (app) => {
|
||||
Bun.resolveSync("typescript/lib/tsserver.js", app.path.cwd)
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,193 @@
|
||||
import { createCli, type TrpcCliMeta } from "trpc-cli"
|
||||
import { initTRPC } from "@trpc/server"
|
||||
import { z } from "zod"
|
||||
import { Server } from "../server/server"
|
||||
import { AuthAnthropic } from "../auth/anthropic"
|
||||
import { UI } from "./ui"
|
||||
import { App } from "../app/app"
|
||||
import { Bus } from "../bus"
|
||||
import { Provider } from "../provider/provider"
|
||||
import { Session } from "../session"
|
||||
import { Share } from "../share/share"
|
||||
import { Message } from "../session/message"
|
||||
import { VERSION } from "./version"
|
||||
import { LSP } from "../lsp"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
|
||||
const t = initTRPC.meta<TrpcCliMeta>().create()
|
||||
|
||||
export const router = t.router({
|
||||
generate: t.procedure
|
||||
.meta({
|
||||
description: "Generate OpenAPI and event specs",
|
||||
})
|
||||
.input(z.object({}))
|
||||
.mutation(async () => {
|
||||
const specs = await Server.openapi()
|
||||
const dir = "gen"
|
||||
await fs.rmdir(dir, { recursive: true }).catch(() => {})
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
await Bun.write(
|
||||
path.join(dir, "openapi.json"),
|
||||
JSON.stringify(specs, null, 2),
|
||||
)
|
||||
return "Generated OpenAPI specs in gen/ directory"
|
||||
}),
|
||||
|
||||
run: t.procedure
|
||||
.meta({
|
||||
description: "Run OpenCode with a message",
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
message: z.array(z.string()).default([]).describe("Message to send"),
|
||||
session: z.string().optional().describe("Session ID to continue"),
|
||||
}),
|
||||
)
|
||||
.mutation(
|
||||
async ({ input }: { input: { message: string[]; session?: string } }) => {
|
||||
const message = input.message.join(" ")
|
||||
await App.provide(
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
version: "0.0.0",
|
||||
},
|
||||
async () => {
|
||||
await Share.init()
|
||||
const session = input.session
|
||||
? await Session.get(input.session)
|
||||
: await Session.create()
|
||||
|
||||
UI.println(UI.Style.TEXT_HIGHLIGHT_BOLD + "◍ OpenCode", VERSION)
|
||||
UI.empty()
|
||||
UI.println(UI.Style.TEXT_NORMAL_BOLD + "> ", message)
|
||||
UI.empty()
|
||||
UI.println(
|
||||
UI.Style.TEXT_INFO_BOLD +
|
||||
"~ https://dev.opencode.ai/s?id=" +
|
||||
session.id.slice(-8),
|
||||
)
|
||||
UI.empty()
|
||||
|
||||
function printEvent(color: string, type: string, title: string) {
|
||||
UI.println(
|
||||
color + `|`,
|
||||
UI.Style.TEXT_NORMAL +
|
||||
UI.Style.TEXT_DIM +
|
||||
` ${type.padEnd(7, " ")}`,
|
||||
"",
|
||||
UI.Style.TEXT_NORMAL + title,
|
||||
)
|
||||
}
|
||||
|
||||
Bus.subscribe(Message.Event.PartUpdated, async (message) => {
|
||||
const part = message.properties.part
|
||||
if (
|
||||
part.type === "tool-invocation" &&
|
||||
part.toolInvocation.state === "result"
|
||||
) {
|
||||
if (part.toolInvocation.toolName === "opencode_todowrite")
|
||||
return
|
||||
|
||||
const args = part.toolInvocation.args as any
|
||||
const tool = part.toolInvocation.toolName
|
||||
|
||||
if (tool === "opencode_edit")
|
||||
printEvent(UI.Style.TEXT_SUCCESS_BOLD, "Edit", args.filePath)
|
||||
if (tool === "opencode_bash")
|
||||
printEvent(
|
||||
UI.Style.TEXT_WARNING_BOLD,
|
||||
"Execute",
|
||||
args.command,
|
||||
)
|
||||
if (tool === "opencode_read")
|
||||
printEvent(UI.Style.TEXT_INFO_BOLD, "Read", args.filePath)
|
||||
if (tool === "opencode_write")
|
||||
printEvent(
|
||||
UI.Style.TEXT_SUCCESS_BOLD,
|
||||
"Create",
|
||||
args.filePath,
|
||||
)
|
||||
if (tool === "opencode_list")
|
||||
printEvent(UI.Style.TEXT_INFO_BOLD, "List", args.path)
|
||||
if (tool === "opencode_glob")
|
||||
printEvent(
|
||||
UI.Style.TEXT_INFO_BOLD,
|
||||
"Glob",
|
||||
args.pattern + (args.path ? " in " + args.path : ""),
|
||||
)
|
||||
}
|
||||
|
||||
if (part.type === "text") {
|
||||
if (part.text.includes("\n")) {
|
||||
UI.empty()
|
||||
UI.println(part.text)
|
||||
UI.empty()
|
||||
return
|
||||
}
|
||||
printEvent(UI.Style.TEXT_NORMAL_BOLD, "Text", part.text)
|
||||
}
|
||||
})
|
||||
|
||||
const { providerID, modelID } = await Provider.defaultModel()
|
||||
await Session.chat({
|
||||
sessionID: session.id,
|
||||
providerID,
|
||||
modelID,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: message,
|
||||
},
|
||||
],
|
||||
})
|
||||
UI.empty()
|
||||
},
|
||||
)
|
||||
return "Session completed"
|
||||
},
|
||||
),
|
||||
|
||||
scrap: t.procedure
|
||||
.meta({
|
||||
description: "Test command for scraping files",
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
file: z.string().describe("File to process"),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input }: { input: { file: string } }) => {
|
||||
await App.provide({ cwd: process.cwd(), version: VERSION }, async () => {
|
||||
await LSP.touchFile(input.file, true)
|
||||
await LSP.diagnostics()
|
||||
})
|
||||
return `Processed file: ${input.file}`
|
||||
}),
|
||||
|
||||
login: t.router({
|
||||
anthropic: t.procedure
|
||||
.meta({
|
||||
description: "Login to Anthropic",
|
||||
})
|
||||
.input(z.object({}))
|
||||
.mutation(async () => {
|
||||
const { url, verifier } = await AuthAnthropic.authorize()
|
||||
|
||||
UI.println("Login to Anthropic")
|
||||
UI.println("Open the following URL in your browser:")
|
||||
UI.println(url)
|
||||
UI.println("")
|
||||
|
||||
const code = await UI.input("Paste the authorization code here: ")
|
||||
await AuthAnthropic.exchange(code, verifier)
|
||||
return "Successfully logged in to Anthropic"
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
export function createOpenCodeCli() {
|
||||
return createCli({ router })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { z } from "zod"
|
||||
import { NamedError } from "../util/error"
|
||||
|
||||
export namespace UI {
|
||||
const LOGO = [
|
||||
`█▀▀█ █▀▀█ █▀▀ █▀▀▄ █▀▀ █▀▀█ █▀▀▄ █▀▀`,
|
||||
`█░░█ █░░█ █▀▀ █░░█ █░░ █░░█ █░░█ █▀▀`,
|
||||
`▀▀▀▀ █▀▀▀ ▀▀▀ ▀ ▀ ▀▀▀ ▀▀▀▀ ▀▀▀ ▀▀▀`,
|
||||
]
|
||||
|
||||
export const CancelledError = NamedError.create("UICancelledError", z.void())
|
||||
|
||||
export const Style = {
|
||||
TEXT_HIGHLIGHT: "\x1b[96m",
|
||||
TEXT_HIGHLIGHT_BOLD: "\x1b[96m\x1b[1m",
|
||||
TEXT_DIM: "\x1b[90m",
|
||||
TEXT_DIM_BOLD: "\x1b[90m\x1b[1m",
|
||||
TEXT_NORMAL: "\x1b[0m",
|
||||
TEXT_NORMAL_BOLD: "\x1b[1m",
|
||||
TEXT_WARNING: "\x1b[93m",
|
||||
TEXT_WARNING_BOLD: "\x1b[93m\x1b[1m",
|
||||
TEXT_DANGER: "\x1b[91m",
|
||||
TEXT_DANGER_BOLD: "\x1b[91m\x1b[1m",
|
||||
TEXT_SUCCESS: "\x1b[92m",
|
||||
TEXT_SUCCESS_BOLD: "\x1b[92m\x1b[1m",
|
||||
TEXT_INFO: "\x1b[94m",
|
||||
TEXT_INFO_BOLD: "\x1b[94m\x1b[1m",
|
||||
}
|
||||
|
||||
export function println(...message: string[]) {
|
||||
print(...message)
|
||||
Bun.stderr.write("\n")
|
||||
}
|
||||
|
||||
export function print(...message: string[]) {
|
||||
blank = false
|
||||
Bun.stderr.write(message.join(" "))
|
||||
}
|
||||
|
||||
let blank = false
|
||||
export function empty() {
|
||||
if (blank) return
|
||||
println("" + Style.TEXT_NORMAL)
|
||||
blank = true
|
||||
}
|
||||
|
||||
export function logo(pad?: string) {
|
||||
const result = []
|
||||
for (const row of LOGO) {
|
||||
if (pad) result.push(pad)
|
||||
for (let i = 0; i < row.length; i++) {
|
||||
const color =
|
||||
i > 18 ? Bun.color("white", "ansi") : Bun.color("gray", "ansi")
|
||||
const char = row[i]
|
||||
result.push(color + char)
|
||||
}
|
||||
result.push("\n")
|
||||
}
|
||||
return result.join("").trimEnd()
|
||||
}
|
||||
|
||||
export async function input(prompt: string): Promise<string> {
|
||||
const readline = require("readline")
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
})
|
||||
|
||||
return new Promise((resolve) => {
|
||||
rl.question(prompt, (answer: string) => {
|
||||
rl.close()
|
||||
resolve(answer.trim())
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
declare global {
|
||||
const OPENCODE_VERSION: string
|
||||
}
|
||||
|
||||
export const VERSION =
|
||||
typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "dev"
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Log } from "../util/log"
|
||||
import { z } from "zod"
|
||||
import { App } from "../app/app"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
|
||||
export namespace Config {
|
||||
const log = Log.create({ service: "config" })
|
||||
|
||||
export const state = App.state("config", async (app) => {
|
||||
let result: Info = {}
|
||||
for (const file of ["opencode.jsonc", "opencode.json"]) {
|
||||
const [resolved] = await Filesystem.findUp(
|
||||
file,
|
||||
app.path.cwd,
|
||||
app.path.root,
|
||||
)
|
||||
if (!resolved) continue
|
||||
try {
|
||||
result = await import(resolved).then((mod) => Info.parse(mod.default))
|
||||
log.info("found", { path: resolved })
|
||||
break
|
||||
} catch (e) {
|
||||
if (e instanceof z.ZodError) {
|
||||
for (const issue of e.issues) {
|
||||
log.info(issue.message)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
log.info("loaded", result)
|
||||
return result
|
||||
})
|
||||
|
||||
export const McpLocal = z.object({
|
||||
type: z.literal("local"),
|
||||
command: z.string().array(),
|
||||
environment: z.record(z.string(), z.string()).optional(),
|
||||
})
|
||||
|
||||
export const McpRemote = z.object({
|
||||
type: z.literal("remote"),
|
||||
url: z.string(),
|
||||
})
|
||||
|
||||
export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote])
|
||||
export type Mcp = z.infer<typeof Mcp>
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
provider: z.record(z.string(), z.record(z.string(), z.any())).optional(),
|
||||
tool: z
|
||||
.object({
|
||||
provider: z.record(z.string(), z.string().array()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
mcp: z.record(z.string(), Mcp).optional(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type Info = z.output<typeof Info>
|
||||
|
||||
export function get() {
|
||||
return state()
|
||||
}
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
import { App } from "../app/app"
|
||||
import path from "path"
|
||||
import { Global } from "../global"
|
||||
import fs from "fs/promises"
|
||||
import { z } from "zod"
|
||||
import { NamedError } from "../util/error"
|
||||
import { lazy } from "../util/lazy"
|
||||
import { Log } from "../util/log"
|
||||
|
||||
export namespace Fzf {
|
||||
const log = Log.create({ service: "fzf" })
|
||||
|
||||
const VERSION = "0.62.0"
|
||||
const PLATFORM = {
|
||||
darwin: { extension: "tar.gz" },
|
||||
linux: { extension: "tar.gz" },
|
||||
win32: { extension: "zip" },
|
||||
} as const
|
||||
|
||||
export const ExtractionFailedError = NamedError.create(
|
||||
"FzfExtractionFailedError",
|
||||
z.object({
|
||||
filepath: z.string(),
|
||||
stderr: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
export const UnsupportedPlatformError = NamedError.create(
|
||||
"FzfUnsupportedPlatformError",
|
||||
z.object({
|
||||
platform: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
export const DownloadFailedError = NamedError.create(
|
||||
"FzfDownloadFailedError",
|
||||
z.object({
|
||||
url: z.string(),
|
||||
status: z.number(),
|
||||
}),
|
||||
)
|
||||
|
||||
const state = lazy(async () => {
|
||||
let filepath = Bun.which("fzf")
|
||||
if (filepath) {
|
||||
log.info("found", { filepath })
|
||||
return { filepath }
|
||||
}
|
||||
filepath = path.join(
|
||||
Global.Path.bin,
|
||||
"fzf" + (process.platform === "win32" ? ".exe" : ""),
|
||||
)
|
||||
|
||||
const file = Bun.file(filepath)
|
||||
if (!(await file.exists())) {
|
||||
const archMap = { x64: "amd64", arm64: "arm64" } as const
|
||||
const arch = archMap[process.arch as keyof typeof archMap] ?? "amd64"
|
||||
|
||||
const config = PLATFORM[process.platform as keyof typeof PLATFORM]
|
||||
if (!config)
|
||||
throw new UnsupportedPlatformError({ platform: process.platform })
|
||||
|
||||
const version = VERSION
|
||||
const platformName =
|
||||
process.platform === "win32" ? "windows" : process.platform
|
||||
const filename = `fzf-${version}-${platformName}_${arch}.${config.extension}`
|
||||
const url = `https://github.com/junegunn/fzf/releases/download/v${version}/${filename}`
|
||||
|
||||
const response = await fetch(url)
|
||||
if (!response.ok)
|
||||
throw new DownloadFailedError({ url, status: response.status })
|
||||
|
||||
const buffer = await response.arrayBuffer()
|
||||
const archivePath = path.join(Global.Path.bin, filename)
|
||||
await Bun.write(archivePath, buffer)
|
||||
if (config.extension === "tar.gz") {
|
||||
const proc = Bun.spawn(["tar", "-xzf", archivePath, "fzf"], {
|
||||
cwd: Global.Path.bin,
|
||||
stderr: "pipe",
|
||||
stdout: "pipe",
|
||||
})
|
||||
await proc.exited
|
||||
if (proc.exitCode !== 0)
|
||||
throw new ExtractionFailedError({
|
||||
filepath,
|
||||
stderr: await Bun.readableStreamToText(proc.stderr),
|
||||
})
|
||||
}
|
||||
if (config.extension === "zip") {
|
||||
const proc = Bun.spawn(
|
||||
["unzip", "-j", archivePath, "fzf.exe", "-d", Global.Path.bin],
|
||||
{
|
||||
cwd: Global.Path.bin,
|
||||
stderr: "pipe",
|
||||
stdout: "ignore",
|
||||
},
|
||||
)
|
||||
await proc.exited
|
||||
if (proc.exitCode !== 0)
|
||||
throw new ExtractionFailedError({
|
||||
filepath: archivePath,
|
||||
stderr: await Bun.readableStreamToText(proc.stderr),
|
||||
})
|
||||
}
|
||||
await fs.unlink(archivePath)
|
||||
if (process.platform !== "win32") await fs.chmod(filepath, 0o755)
|
||||
}
|
||||
|
||||
return {
|
||||
filepath,
|
||||
}
|
||||
})
|
||||
|
||||
export async function filepath() {
|
||||
const { filepath } = await state()
|
||||
return filepath
|
||||
}
|
||||
|
||||
export async function search(cwd: string, query: string) {
|
||||
const process = Bun.spawn({
|
||||
cwd,
|
||||
stdin: "inherit",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
cmd: [await filepath(), "--filter", query],
|
||||
})
|
||||
await process.exited
|
||||
const stdout = await Bun.readableStreamToText(process.stdout)
|
||||
return stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((line) => line.length > 0)
|
||||
}
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
import { App } from "../app/app"
|
||||
import path from "path"
|
||||
import { Global } from "../global"
|
||||
import fs from "fs/promises"
|
||||
import { z } from "zod"
|
||||
import { NamedError } from "../util/error"
|
||||
import { lazy } from "../util/lazy"
|
||||
|
||||
export namespace Ripgrep {
|
||||
const PLATFORM = {
|
||||
darwin: { platform: "apple-darwin", extension: "tar.gz" },
|
||||
linux: { platform: "unknown-linux-musl", extension: "tar.gz" },
|
||||
win32: { platform: "pc-windows-msvc", extension: "zip" },
|
||||
} as const
|
||||
|
||||
export const ExtractionFailedError = NamedError.create(
|
||||
"RipgrepExtractionFailedError",
|
||||
z.object({
|
||||
filepath: z.string(),
|
||||
stderr: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
export const UnsupportedPlatformError = NamedError.create(
|
||||
"RipgrepUnsupportedPlatformError",
|
||||
z.object({
|
||||
platform: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
export const DownloadFailedError = NamedError.create(
|
||||
"RipgrepDownloadFailedError",
|
||||
z.object({
|
||||
url: z.string(),
|
||||
status: z.number(),
|
||||
}),
|
||||
)
|
||||
|
||||
const state = lazy(async () => {
|
||||
let filepath = Bun.which("rg")
|
||||
if (filepath) return { filepath }
|
||||
filepath = path.join(
|
||||
Global.Path.bin,
|
||||
"rg" + (process.platform === "win32" ? ".exe" : ""),
|
||||
)
|
||||
|
||||
const file = Bun.file(filepath)
|
||||
if (!(await file.exists())) {
|
||||
const archMap = { x64: "x86_64", arm64: "aarch64" } as const
|
||||
const arch = archMap[process.arch as keyof typeof archMap] ?? process.arch
|
||||
|
||||
const config = PLATFORM[process.platform as keyof typeof PLATFORM]
|
||||
if (!config)
|
||||
throw new UnsupportedPlatformError({ platform: process.platform })
|
||||
|
||||
const version = "14.1.1"
|
||||
const filename = `ripgrep-${version}-${arch}-${config.platform}.${config.extension}`
|
||||
const url = `https://github.com/BurntSushi/ripgrep/releases/download/${version}/${filename}`
|
||||
|
||||
const response = await fetch(url)
|
||||
if (!response.ok)
|
||||
throw new DownloadFailedError({ url, status: response.status })
|
||||
|
||||
const buffer = await response.arrayBuffer()
|
||||
const archivePath = path.join(Global.Path.bin, filename)
|
||||
await Bun.write(archivePath, buffer)
|
||||
if (config.extension === "tar.gz") {
|
||||
const args = ["tar", "-xzf", archivePath, "--strip-components=1"]
|
||||
|
||||
if (process.platform === "darwin") args.push("--include=*/rg")
|
||||
if (process.platform === "linux") args.push("--wildcards", "*/rg")
|
||||
|
||||
const proc = Bun.spawn(args, {
|
||||
cwd: Global.Path.bin,
|
||||
stderr: "pipe",
|
||||
stdout: "pipe",
|
||||
})
|
||||
await proc.exited
|
||||
if (proc.exitCode !== 0)
|
||||
throw new ExtractionFailedError({
|
||||
filepath,
|
||||
stderr: await Bun.readableStreamToText(proc.stderr),
|
||||
})
|
||||
}
|
||||
if (config.extension === "zip") {
|
||||
const proc = Bun.spawn(
|
||||
["unzip", "-j", archivePath, "*/rg.exe", "-d", Global.Path.bin],
|
||||
{
|
||||
cwd: Global.Path.bin,
|
||||
stderr: "pipe",
|
||||
stdout: "ignore",
|
||||
},
|
||||
)
|
||||
await proc.exited
|
||||
if (proc.exitCode !== 0)
|
||||
throw new ExtractionFailedError({
|
||||
filepath: archivePath,
|
||||
stderr: await Bun.readableStreamToText(proc.stderr),
|
||||
})
|
||||
}
|
||||
await fs.unlink(archivePath)
|
||||
if (process.platform !== "win32") await fs.chmod(filepath, 0o755)
|
||||
}
|
||||
|
||||
return {
|
||||
filepath,
|
||||
}
|
||||
})
|
||||
|
||||
export async function filepath() {
|
||||
const { filepath } = await state()
|
||||
return filepath
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export namespace File {
|
||||
const glob = new Bun.Glob("**/*")
|
||||
export async function search(path: string, query: string) {
|
||||
for await (const entry of glob.scan({
|
||||
cwd: path,
|
||||
onlyFiles: true,
|
||||
})) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import fs from "fs/promises"
|
||||
import { xdgData, xdgCache, xdgConfig } from "xdg-basedir"
|
||||
import path from "path"
|
||||
|
||||
const app = "opencode"
|
||||
|
||||
const data = path.join(xdgData!, app)
|
||||
const cache = path.join(xdgCache!, app)
|
||||
const config = path.join(xdgConfig!, app)
|
||||
|
||||
await Promise.all([
|
||||
fs.mkdir(data, { recursive: true }),
|
||||
fs.mkdir(config, { recursive: true }),
|
||||
fs.mkdir(cache, { recursive: true }),
|
||||
])
|
||||
|
||||
export namespace Global {
|
||||
export const Path = {
|
||||
data,
|
||||
bin: path.join(data, "bin"),
|
||||
cache,
|
||||
config,
|
||||
} as const
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { z } from "zod"
|
||||
import { randomBytes } from "crypto"
|
||||
|
||||
export namespace Identifier {
|
||||
const prefixes = {
|
||||
session: "ses",
|
||||
message: "msg",
|
||||
user: "usr",
|
||||
} as const
|
||||
|
||||
export function schema(prefix: keyof typeof prefixes) {
|
||||
return z.string().startsWith(prefixes[prefix])
|
||||
}
|
||||
|
||||
const LENGTH = 26
|
||||
|
||||
// State for monotonic ID generation
|
||||
let lastTimestamp = 0
|
||||
let counter = 0
|
||||
|
||||
export function ascending(prefix: keyof typeof prefixes, given?: string) {
|
||||
return generateID(prefix, false, given)
|
||||
}
|
||||
|
||||
export function descending(prefix: keyof typeof prefixes, given?: string) {
|
||||
return generateID(prefix, true, given)
|
||||
}
|
||||
|
||||
function generateID(
|
||||
prefix: keyof typeof prefixes,
|
||||
descending: boolean,
|
||||
given?: string,
|
||||
): string {
|
||||
if (!given) {
|
||||
return generateNewID(prefix, descending)
|
||||
}
|
||||
|
||||
if (!given.startsWith(prefixes[prefix])) {
|
||||
throw new Error(`ID ${given} does not start with ${prefixes[prefix]}`)
|
||||
}
|
||||
return given
|
||||
}
|
||||
|
||||
function generateNewID(
|
||||
prefix: keyof typeof prefixes,
|
||||
descending: boolean,
|
||||
): string {
|
||||
const currentTimestamp = Date.now()
|
||||
|
||||
if (currentTimestamp !== lastTimestamp) {
|
||||
lastTimestamp = currentTimestamp
|
||||
counter = 0
|
||||
}
|
||||
counter++
|
||||
|
||||
let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter)
|
||||
|
||||
now = descending ? ~now : now
|
||||
|
||||
const timeBytes = Buffer.alloc(6)
|
||||
for (let i = 0; i < 6; i++) {
|
||||
timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff))
|
||||
}
|
||||
|
||||
const randLength = (LENGTH - 12) / 2
|
||||
const random = randomBytes(randLength)
|
||||
|
||||
return (
|
||||
prefixes[prefix] +
|
||||
"_" +
|
||||
timeBytes.toString("hex") +
|
||||
random.toString("hex")
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import "zod-openapi/extend"
|
||||
import { App } from "./app/app"
|
||||
import { Server } from "./server/server"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
|
||||
import { Share } from "./share/share"
|
||||
|
||||
import { Global } from "./global"
|
||||
|
||||
import yargs from "yargs"
|
||||
import { hideBin } from "yargs/helpers"
|
||||
import { RunCommand } from "./cli/cmd/run"
|
||||
import { GenerateCommand } from "./cli/cmd/generate"
|
||||
import { VERSION } from "./cli/version"
|
||||
import { ScrapCommand } from "./cli/cmd/scrap"
|
||||
import { Log } from "./util/log"
|
||||
import { AuthCommand, AuthLoginCommand } from "./cli/cmd/auth"
|
||||
import { Provider } from "./provider/provider"
|
||||
import { UI } from "./cli/ui"
|
||||
|
||||
const cli = yargs(hideBin(process.argv))
|
||||
.scriptName("opencode")
|
||||
.version(VERSION)
|
||||
.option("print-logs", {
|
||||
describe: "Print logs to stderr",
|
||||
type: "boolean",
|
||||
})
|
||||
.middleware(async () => {
|
||||
await Log.init({ print: process.argv.includes("--print-logs") })
|
||||
Log.Default.info("opencode", {
|
||||
version: VERSION,
|
||||
args: process.argv.slice(2),
|
||||
})
|
||||
})
|
||||
.usage("\n" + UI.logo())
|
||||
.command({
|
||||
command: "$0",
|
||||
describe: "Start OpenCode TUI",
|
||||
handler: async (args) => {
|
||||
while (true) {
|
||||
const result = await App.provide(
|
||||
{ cwd: process.cwd(), version: VERSION },
|
||||
async () => {
|
||||
const providers = await Provider.list()
|
||||
if (Object.keys(providers).length === 0) {
|
||||
return "needs_provider"
|
||||
}
|
||||
|
||||
await Share.init()
|
||||
const server = Server.listen()
|
||||
|
||||
let cmd = ["go", "run", "./main.go"]
|
||||
let cwd = new URL("../../tui/cmd/opencode", import.meta.url)
|
||||
.pathname
|
||||
if (Bun.embeddedFiles.length > 0) {
|
||||
const blob = Bun.embeddedFiles[0] as File
|
||||
const binary = path.join(Global.Path.cache, "tui", blob.name)
|
||||
const file = Bun.file(binary)
|
||||
if (!(await file.exists())) {
|
||||
await Bun.write(file, blob, { mode: 0o755 })
|
||||
await fs.chmod(binary, 0o755)
|
||||
}
|
||||
cwd = process.cwd()
|
||||
cmd = [binary]
|
||||
}
|
||||
const proc = Bun.spawn({
|
||||
cmd,
|
||||
cwd,
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
stdin: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCODE_SERVER: server.url.toString(),
|
||||
},
|
||||
onExit: () => {
|
||||
server.stop()
|
||||
},
|
||||
})
|
||||
await proc.exited
|
||||
await server.stop()
|
||||
|
||||
return "done"
|
||||
},
|
||||
)
|
||||
if (result === "done") break
|
||||
if (result === "needs_provider") {
|
||||
UI.empty()
|
||||
UI.println(UI.logo(" "))
|
||||
UI.empty()
|
||||
await AuthLoginCommand.handler(args)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
.command(RunCommand)
|
||||
.command(GenerateCommand)
|
||||
.command(ScrapCommand)
|
||||
.command(AuthCommand)
|
||||
.fail((msg, err) => {
|
||||
if (
|
||||
msg.startsWith("Unknown argument") ||
|
||||
msg.startsWith("Not enough non-option arguments")
|
||||
) {
|
||||
cli.showHelp("log")
|
||||
}
|
||||
Log.Default.error(msg, {
|
||||
err,
|
||||
})
|
||||
})
|
||||
.strict()
|
||||
|
||||
try {
|
||||
await cli.parse()
|
||||
} catch (e) {
|
||||
Log.Default.error(e)
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import path from "path"
|
||||
import {
|
||||
createMessageConnection,
|
||||
StreamMessageReader,
|
||||
StreamMessageWriter,
|
||||
} from "vscode-jsonrpc/node"
|
||||
import type { Diagnostic as VSCodeDiagnostic } from "vscode-languageserver-types"
|
||||
import { App } from "../app/app"
|
||||
import { Log } from "../util/log"
|
||||
import { LANGUAGE_EXTENSIONS } from "./language"
|
||||
import { Bus } from "../bus"
|
||||
import z from "zod"
|
||||
import type { LSPServer } from "./server"
|
||||
|
||||
export namespace LSPClient {
|
||||
const log = Log.create({ service: "lsp.client" })
|
||||
|
||||
export type Info = NonNullable<Awaited<ReturnType<typeof create>>>
|
||||
|
||||
export type Diagnostic = VSCodeDiagnostic
|
||||
|
||||
export const Event = {
|
||||
Diagnostics: Bus.event(
|
||||
"lsp.client.diagnostics",
|
||||
z.object({
|
||||
serverID: z.string(),
|
||||
path: z.string(),
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
export async function create(serverID: string, server: LSPServer.Handle) {
|
||||
const app = App.info()
|
||||
log.info("starting client", { id: serverID })
|
||||
|
||||
const connection = createMessageConnection(
|
||||
new StreamMessageReader(server.process.stdout),
|
||||
new StreamMessageWriter(server.process.stdin),
|
||||
)
|
||||
|
||||
const diagnostics = new Map<string, Diagnostic[]>()
|
||||
connection.onNotification("textDocument/publishDiagnostics", (params) => {
|
||||
const path = new URL(params.uri).pathname
|
||||
log.info("textDocument/publishDiagnostics", {
|
||||
path,
|
||||
})
|
||||
diagnostics.set(path, params.diagnostics)
|
||||
Bus.publish(Event.Diagnostics, { path, serverID })
|
||||
})
|
||||
connection.onRequest("workspace/configuration", async () => {
|
||||
return [{}]
|
||||
})
|
||||
connection.listen()
|
||||
|
||||
await connection.sendRequest("initialize", {
|
||||
processId: server.process.pid,
|
||||
workspaceFolders: [
|
||||
{
|
||||
name: "workspace",
|
||||
uri: "file://" + app.path.cwd,
|
||||
},
|
||||
],
|
||||
initializationOptions: {
|
||||
...server.initialization,
|
||||
},
|
||||
capabilities: {
|
||||
workspace: {
|
||||
configuration: true,
|
||||
},
|
||||
textDocument: {
|
||||
synchronization: {
|
||||
didOpen: true,
|
||||
didChange: true,
|
||||
},
|
||||
publishDiagnostics: {
|
||||
versionSupport: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
await connection.sendNotification("initialized", {})
|
||||
log.info("initialized")
|
||||
|
||||
const files: {
|
||||
[path: string]: number
|
||||
} = {}
|
||||
|
||||
const result = {
|
||||
get serverID() {
|
||||
return serverID
|
||||
},
|
||||
get connection() {
|
||||
return connection
|
||||
},
|
||||
notify: {
|
||||
async open(input: { path: string }) {
|
||||
input.path = path.isAbsolute(input.path)
|
||||
? input.path
|
||||
: path.resolve(app.path.cwd, input.path)
|
||||
const file = Bun.file(input.path)
|
||||
const text = await file.text()
|
||||
const version = files[input.path]
|
||||
if (version === undefined) {
|
||||
log.info("textDocument/didOpen", input)
|
||||
diagnostics.delete(input.path)
|
||||
const extension = path.extname(input.path)
|
||||
const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"
|
||||
await connection.sendNotification("textDocument/didOpen", {
|
||||
textDocument: {
|
||||
uri: `file://` + input.path,
|
||||
languageId,
|
||||
version: 0,
|
||||
text,
|
||||
},
|
||||
})
|
||||
files[input.path] = 0
|
||||
return
|
||||
}
|
||||
|
||||
log.info("textDocument/didChange", input)
|
||||
diagnostics.delete(input.path)
|
||||
await connection.sendNotification("textDocument/didChange", {
|
||||
textDocument: {
|
||||
uri: `file://` + input.path,
|
||||
version: ++files[input.path],
|
||||
},
|
||||
contentChanges: [
|
||||
{
|
||||
text,
|
||||
},
|
||||
],
|
||||
})
|
||||
},
|
||||
},
|
||||
get diagnostics() {
|
||||
return diagnostics
|
||||
},
|
||||
async waitForDiagnostics(input: { path: string }) {
|
||||
input.path = path.isAbsolute(input.path)
|
||||
? input.path
|
||||
: path.resolve(app.path.cwd, input.path)
|
||||
log.info("waiting for diagnostics", input)
|
||||
let unsub: () => void
|
||||
let timeout: NodeJS.Timeout
|
||||
return await Promise.race([
|
||||
new Promise<void>(async (resolve) => {
|
||||
unsub = Bus.subscribe(Event.Diagnostics, (event) => {
|
||||
if (
|
||||
event.properties.path === input.path &&
|
||||
event.properties.serverID === result.serverID
|
||||
) {
|
||||
log.info("got diagnostics", input)
|
||||
clearTimeout(timeout)
|
||||
unsub?.()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
}),
|
||||
new Promise<void>((resolve) => {
|
||||
timeout = setTimeout(() => {
|
||||
log.info("timed out refreshing diagnostics", input)
|
||||
unsub?.()
|
||||
resolve()
|
||||
}, 5000)
|
||||
}),
|
||||
])
|
||||
},
|
||||
async shutdown() {
|
||||
log.info("shutting down")
|
||||
connection.end()
|
||||
connection.dispose()
|
||||
server.process.kill("SIGKILL")
|
||||
},
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { App } from "../app/app"
|
||||
import { Log } from "../util/log"
|
||||
import { LSPClient } from "./client"
|
||||
import path from "path"
|
||||
import { LSPServer } from "./server"
|
||||
|
||||
export namespace LSP {
|
||||
const log = Log.create({ service: "lsp" })
|
||||
|
||||
const state = App.state(
|
||||
"lsp",
|
||||
async () => {
|
||||
log.info("initializing")
|
||||
const clients = new Map<string, LSPClient.Info>()
|
||||
|
||||
return {
|
||||
clients,
|
||||
}
|
||||
},
|
||||
async (state) => {
|
||||
for (const client of state.clients.values()) {
|
||||
await client.shutdown()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export async function touchFile(input: string, waitForDiagnostics?: boolean) {
|
||||
const extension = path.parse(input).ext
|
||||
const s = await state()
|
||||
const matches = LSPServer.All.filter((x) =>
|
||||
x.extensions.includes(extension),
|
||||
)
|
||||
for (const match of matches) {
|
||||
const existing = s.clients.get(match.id)
|
||||
if (existing) continue
|
||||
const handle = await match.spawn(App.info())
|
||||
if (!handle) continue
|
||||
const client = await LSPClient.create(match.id, handle)
|
||||
s.clients.set(match.id, client)
|
||||
}
|
||||
if (waitForDiagnostics) {
|
||||
await run(async (client) => {
|
||||
const wait = client.waitForDiagnostics({ path: input })
|
||||
await client.notify.open({ path: input })
|
||||
return wait
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export async function diagnostics() {
|
||||
const results: Record<string, LSPClient.Diagnostic[]> = {}
|
||||
for (const result of await run(async (client) => client.diagnostics)) {
|
||||
for (const [path, diagnostics] of result.entries()) {
|
||||
const arr = results[path] || []
|
||||
arr.push(...diagnostics)
|
||||
results[path] = arr
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
export async function hover(input: {
|
||||
file: string
|
||||
line: number
|
||||
character: number
|
||||
}) {
|
||||
return run((client) => {
|
||||
return client.connection.sendRequest("textDocument/hover", {
|
||||
textDocument: {
|
||||
uri: `file://${input.file}`,
|
||||
},
|
||||
position: {
|
||||
line: input.line,
|
||||
character: input.character,
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function run<T>(
|
||||
input: (client: LSPClient.Info) => Promise<T>,
|
||||
): Promise<T[]> {
|
||||
const clients = await state().then((x) => [...x.clients.values()])
|
||||
const tasks = clients.map((x) => input(x))
|
||||
return Promise.all(tasks)
|
||||
}
|
||||
|
||||
export namespace Diagnostic {
|
||||
export function pretty(diagnostic: LSPClient.Diagnostic) {
|
||||
const severityMap = {
|
||||
1: "ERROR",
|
||||
2: "WARN",
|
||||
3: "INFO",
|
||||
4: "HINT",
|
||||
}
|
||||
|
||||
const severity = severityMap[diagnostic.severity || 1]
|
||||
const line = diagnostic.range.start.line + 1
|
||||
const col = diagnostic.range.start.character + 1
|
||||
|
||||
return `${severity} [${line}:${col}] ${diagnostic.message}`
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
export const LANGUAGE_EXTENSIONS: Record<string, string> = {
|
||||
".abap": "abap",
|
||||
".bat": "bat",
|
||||
".bib": "bibtex",
|
||||
".bibtex": "bibtex",
|
||||
".clj": "clojure",
|
||||
".coffee": "coffeescript",
|
||||
".c": "c",
|
||||
".cpp": "cpp",
|
||||
".cxx": "cpp",
|
||||
".cc": "cpp",
|
||||
".c++": "cpp",
|
||||
".cs": "csharp",
|
||||
".css": "css",
|
||||
".d": "d",
|
||||
".pas": "pascal",
|
||||
".pascal": "pascal",
|
||||
".diff": "diff",
|
||||
".patch": "diff",
|
||||
".dart": "dart",
|
||||
".dockerfile": "dockerfile",
|
||||
".ex": "elixir",
|
||||
".exs": "elixir",
|
||||
".erl": "erlang",
|
||||
".hrl": "erlang",
|
||||
".fs": "fsharp",
|
||||
".fsi": "fsharp",
|
||||
".fsx": "fsharp",
|
||||
".fsscript": "fsharp",
|
||||
".gitcommit": "git-commit",
|
||||
".gitrebase": "git-rebase",
|
||||
".go": "go",
|
||||
".groovy": "groovy",
|
||||
".hbs": "handlebars",
|
||||
".handlebars": "handlebars",
|
||||
".hs": "haskell",
|
||||
".html": "html",
|
||||
".htm": "html",
|
||||
".ini": "ini",
|
||||
".java": "java",
|
||||
".js": "javascript",
|
||||
".jsx": "javascriptreact",
|
||||
".json": "json",
|
||||
".tex": "latex",
|
||||
".latex": "latex",
|
||||
".less": "less",
|
||||
".lua": "lua",
|
||||
".makefile": "makefile",
|
||||
makefile: "makefile",
|
||||
".md": "markdown",
|
||||
".markdown": "markdown",
|
||||
".m": "objective-c",
|
||||
".mm": "objective-cpp",
|
||||
".pl": "perl",
|
||||
".pm": "perl6",
|
||||
".php": "php",
|
||||
".ps1": "powershell",
|
||||
".psm1": "powershell",
|
||||
".pug": "jade",
|
||||
".jade": "jade",
|
||||
".py": "python",
|
||||
".r": "r",
|
||||
".cshtml": "razor",
|
||||
".razor": "razor",
|
||||
".rb": "ruby",
|
||||
".rs": "rust",
|
||||
".scss": "scss",
|
||||
".sass": "sass",
|
||||
".scala": "scala",
|
||||
".shader": "shaderlab",
|
||||
".sh": "shellscript",
|
||||
".bash": "shellscript",
|
||||
".zsh": "shellscript",
|
||||
".ksh": "shellscript",
|
||||
".sql": "sql",
|
||||
".swift": "swift",
|
||||
".ts": "typescript",
|
||||
".tsx": "typescriptreact",
|
||||
".mts": "typescript",
|
||||
".cts": "typescript",
|
||||
".mtsx": "typescriptreact",
|
||||
".ctsx": "typescriptreact",
|
||||
".xml": "xml",
|
||||
".xsl": "xsl",
|
||||
".yaml": "yaml",
|
||||
".yml": "yaml",
|
||||
".mjs": "javascript",
|
||||
".cjs": "javascript",
|
||||
} as const
|
||||
@@ -0,0 +1,93 @@
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "child_process"
|
||||
import type { App } from "../app/app"
|
||||
import path from "path"
|
||||
import { Global } from "../global"
|
||||
import { Log } from "../util/log"
|
||||
import { BunProc } from "../bun"
|
||||
|
||||
export namespace LSPServer {
|
||||
const log = Log.create({ service: "lsp.server" })
|
||||
|
||||
export interface Handle {
|
||||
process: ChildProcessWithoutNullStreams
|
||||
initialization?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface Info {
|
||||
id: string
|
||||
extensions: string[]
|
||||
spawn(app: App.Info): Promise<Handle | undefined>
|
||||
}
|
||||
|
||||
export const All: Info[] = [
|
||||
{
|
||||
id: "typescript",
|
||||
extensions: [
|
||||
".ts",
|
||||
".tsx",
|
||||
".js",
|
||||
".jsx",
|
||||
".mjs",
|
||||
".cjs",
|
||||
".mts",
|
||||
".cts",
|
||||
],
|
||||
async spawn(app) {
|
||||
const tsserver = await Bun.resolve(
|
||||
"typescript/lib/tsserver.js",
|
||||
app.path.cwd,
|
||||
).catch(() => {})
|
||||
if (!tsserver) return
|
||||
const proc = spawn(
|
||||
BunProc.which(),
|
||||
["x", "typescript-language-server", "--stdio"],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
},
|
||||
)
|
||||
return {
|
||||
process: proc,
|
||||
initialization: {
|
||||
tsserver: {
|
||||
path: tsserver,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "golang",
|
||||
extensions: [".go"],
|
||||
async spawn() {
|
||||
let bin = Bun.which("gopls", {
|
||||
PATH: process.env["PATH"] + ":" + Global.Path.bin,
|
||||
})
|
||||
if (!bin) {
|
||||
log.info("installing gopls")
|
||||
const proc = Bun.spawn({
|
||||
cmd: ["go", "install", "golang.org/x/tools/gopls@latest"],
|
||||
env: { ...process.env, GOBIN: Global.Path.bin },
|
||||
})
|
||||
const exit = await proc.exited
|
||||
if (exit !== 0) {
|
||||
log.error("Failed to install gopls")
|
||||
return
|
||||
}
|
||||
bin = path.join(
|
||||
Global.Path.bin,
|
||||
"gopls" + (process.platform === "win32" ? ".exe" : ""),
|
||||
)
|
||||
log.info(`installed gopls`, {
|
||||
bin,
|
||||
})
|
||||
}
|
||||
return {
|
||||
process: spawn(bin!),
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { experimental_createMCPClient, type Tool } from "ai"
|
||||
import { Experimental_StdioMCPTransport } from "ai/mcp-stdio"
|
||||
import { App } from "../app/app"
|
||||
import { Config } from "../config/config"
|
||||
|
||||
export namespace MCP {
|
||||
const state = App.state(
|
||||
"mcp",
|
||||
async () => {
|
||||
const cfg = await Config.get()
|
||||
const clients: {
|
||||
[name: string]: Awaited<ReturnType<typeof experimental_createMCPClient>>
|
||||
} = {}
|
||||
for (const [key, mcp] of Object.entries(cfg.mcp ?? {})) {
|
||||
if (mcp.type === "remote") {
|
||||
clients[key] = await experimental_createMCPClient({
|
||||
name: key,
|
||||
transport: {
|
||||
type: "sse",
|
||||
url: mcp.url,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (mcp.type === "local") {
|
||||
const [cmd, ...args] = mcp.command
|
||||
clients[key] = await experimental_createMCPClient({
|
||||
name: key,
|
||||
transport: new Experimental_StdioMCPTransport({
|
||||
stderr: "ignore",
|
||||
command: cmd,
|
||||
args,
|
||||
env: mcp.environment,
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
clients,
|
||||
}
|
||||
},
|
||||
async (state) => {
|
||||
for (const client of Object.values(state.clients)) {
|
||||
client.close()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export async function clients() {
|
||||
return state().then((state) => state.clients)
|
||||
}
|
||||
|
||||
export async function tools() {
|
||||
const result: Record<string, Tool> = {}
|
||||
for (const [clientName, client] of Object.entries(await clients())) {
|
||||
for (const [toolName, tool] of Object.entries(await client.tools())) {
|
||||
result[clientName + "_" + toolName] = tool
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { App } from "../app/app"
|
||||
import { z } from "zod"
|
||||
import { Bus } from "../bus"
|
||||
import { Log } from "../util/log"
|
||||
|
||||
export namespace Permission {
|
||||
const log = Log.create({ service: "permission" })
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
sessionID: z.string(),
|
||||
title: z.string(),
|
||||
metadata: z.record(z.any()),
|
||||
time: z.object({
|
||||
created: z.number(),
|
||||
}),
|
||||
})
|
||||
.openapi({
|
||||
ref: "permission.info",
|
||||
})
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
export const Event = {
|
||||
Updated: Bus.event("permission.updated", Info),
|
||||
}
|
||||
|
||||
const state = App.state(
|
||||
"permission",
|
||||
() => {
|
||||
const pending: {
|
||||
[sessionID: string]: {
|
||||
[permissionID: string]: {
|
||||
info: Info
|
||||
resolve: () => void
|
||||
reject: (e: any) => void
|
||||
}
|
||||
}
|
||||
} = {}
|
||||
|
||||
const approved: {
|
||||
[sessionID: string]: {
|
||||
[permissionID: string]: Info
|
||||
}
|
||||
} = {}
|
||||
|
||||
return {
|
||||
pending,
|
||||
approved,
|
||||
}
|
||||
},
|
||||
async (state) => {
|
||||
for (const pending of Object.values(state.pending)) {
|
||||
for (const item of Object.values(pending)) {
|
||||
item.reject(new RejectedError(item.info.sessionID, item.info.id))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export function ask(input: {
|
||||
id: Info["id"]
|
||||
sessionID: Info["sessionID"]
|
||||
title: Info["title"]
|
||||
metadata: Info["metadata"]
|
||||
}) {
|
||||
const { pending, approved } = state()
|
||||
log.info("asking", {
|
||||
sessionID: input.sessionID,
|
||||
permissionID: input.id,
|
||||
})
|
||||
if (approved[input.sessionID]?.[input.id]) {
|
||||
log.info("previously approved", {
|
||||
sessionID: input.sessionID,
|
||||
permissionID: input.id,
|
||||
})
|
||||
return
|
||||
}
|
||||
const info: Info = {
|
||||
id: input.id,
|
||||
sessionID: input.sessionID,
|
||||
title: input.title,
|
||||
metadata: input.metadata,
|
||||
time: {
|
||||
created: Date.now(),
|
||||
},
|
||||
}
|
||||
pending[input.sessionID] = pending[input.sessionID] || {}
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
pending[input.sessionID][input.id] = {
|
||||
info,
|
||||
resolve,
|
||||
reject,
|
||||
}
|
||||
setTimeout(() => {
|
||||
respond({
|
||||
sessionID: input.sessionID,
|
||||
permissionID: input.id,
|
||||
response: "always",
|
||||
})
|
||||
}, 1000)
|
||||
Bus.publish(Event.Updated, info)
|
||||
})
|
||||
}
|
||||
|
||||
export function respond(input: {
|
||||
sessionID: Info["sessionID"]
|
||||
permissionID: Info["id"]
|
||||
response: "once" | "always" | "reject"
|
||||
}) {
|
||||
log.info("response", input)
|
||||
const { pending, approved } = state()
|
||||
const match = pending[input.sessionID]?.[input.permissionID]
|
||||
if (!match) return
|
||||
delete pending[input.sessionID][input.permissionID]
|
||||
if (input.response === "reject") {
|
||||
match.reject(new RejectedError(input.sessionID, input.permissionID))
|
||||
return
|
||||
}
|
||||
match.resolve()
|
||||
if (input.response === "always") {
|
||||
approved[input.sessionID] = approved[input.sessionID] || {}
|
||||
approved[input.sessionID][input.permissionID] = match.info
|
||||
}
|
||||
}
|
||||
|
||||
export class RejectedError extends Error {
|
||||
constructor(
|
||||
public readonly sessionID: string,
|
||||
public readonly permissionID: string,
|
||||
) {
|
||||
super(`The user rejected permission to use this functionality`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Global } from "../global"
|
||||
import { Log } from "../util/log"
|
||||
import path from "path"
|
||||
|
||||
export namespace ModelsDev {
|
||||
const log = Log.create({ service: "models.dev" })
|
||||
const filepath = path.join(Global.Path.cache, "models.json")
|
||||
|
||||
export async function get() {
|
||||
const file = Bun.file(filepath)
|
||||
const result = await file.json().catch(() => {})
|
||||
if (result) {
|
||||
refresh()
|
||||
return result
|
||||
}
|
||||
await refresh()
|
||||
return get()
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const file = Bun.file(filepath)
|
||||
log.info("refreshing")
|
||||
const result = await fetch("https://models.dev/api.json")
|
||||
if (!result.ok)
|
||||
throw new Error(`Failed to fetch models.dev: ${result.statusText}`)
|
||||
await Bun.write(file, result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import z from "zod"
|
||||
import { App } from "../app/app"
|
||||
import { Config } from "../config/config"
|
||||
import { mergeDeep, sortBy } from "remeda"
|
||||
import { NoSuchModelError, type LanguageModel, type Provider as SDK } from "ai"
|
||||
import { Log } from "../util/log"
|
||||
import path from "path"
|
||||
import { Global } from "../global"
|
||||
import { BunProc } from "../bun"
|
||||
import { BashTool } from "../tool/bash"
|
||||
import { EditTool } from "../tool/edit"
|
||||
import { WebFetchTool } from "../tool/webfetch"
|
||||
import { GlobTool } from "../tool/glob"
|
||||
import { GrepTool } from "../tool/grep"
|
||||
import { ListTool } from "../tool/ls"
|
||||
import { LspDiagnosticTool } from "../tool/lsp-diagnostics"
|
||||
import { LspHoverTool } from "../tool/lsp-hover"
|
||||
import { PatchTool } from "../tool/patch"
|
||||
import { ReadTool } from "../tool/read"
|
||||
import type { Tool } from "../tool/tool"
|
||||
import { WriteTool } from "../tool/write"
|
||||
import { TodoReadTool, TodoWriteTool } from "../tool/todo"
|
||||
import { AuthAnthropic } from "../auth/anthropic"
|
||||
import { ModelsDev } from "./models"
|
||||
import { NamedError } from "../util/error"
|
||||
import { Auth } from "../auth"
|
||||
|
||||
export namespace Provider {
|
||||
const log = Log.create({ service: "provider" })
|
||||
|
||||
export const Model = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string().optional(),
|
||||
attachment: z.boolean(),
|
||||
reasoning: z.boolean().optional(),
|
||||
cost: z.object({
|
||||
input: z.number(),
|
||||
inputCached: z.number(),
|
||||
output: z.number(),
|
||||
outputCached: z.number(),
|
||||
}),
|
||||
limit: z.object({
|
||||
context: z.number(),
|
||||
output: z.number(),
|
||||
}),
|
||||
})
|
||||
.openapi({
|
||||
ref: "Provider.Model",
|
||||
})
|
||||
export type Model = z.output<typeof Model>
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
models: z.record(z.string(), Model),
|
||||
})
|
||||
.openapi({
|
||||
ref: "Provider.Info",
|
||||
})
|
||||
export type Info = z.output<typeof Info>
|
||||
|
||||
type Autodetector = (provider: Info) => Promise<
|
||||
| {
|
||||
source: Source
|
||||
options: Record<string, any>
|
||||
}
|
||||
| false
|
||||
>
|
||||
|
||||
function env(...keys: string[]) {
|
||||
const result: Autodetector = async () => {
|
||||
for (const key of keys) {
|
||||
if (process.env[key])
|
||||
return {
|
||||
source: "env",
|
||||
options: {},
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
type Source = "oauth" | "env" | "config" | "api"
|
||||
|
||||
const AUTODETECT: Record<string, Autodetector> = {
|
||||
async anthropic(provider) {
|
||||
const access = await AuthAnthropic.access()
|
||||
if (access) {
|
||||
// claude sub doesn't have usage cost
|
||||
for (const model of Object.values(provider.models)) {
|
||||
model.cost = {
|
||||
input: 0,
|
||||
inputCached: 0,
|
||||
output: 0,
|
||||
outputCached: 0,
|
||||
}
|
||||
}
|
||||
return {
|
||||
source: "oauth",
|
||||
options: {
|
||||
apiKey: "",
|
||||
headers: {
|
||||
authorization: `Bearer ${access}`,
|
||||
"anthropic-beta": "oauth-2025-04-20",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
return env("ANTHROPIC_API_KEY")(provider)
|
||||
},
|
||||
google: env("GOOGLE_GENERATIVE_AI_API_KEY"),
|
||||
openai: env("OPENAI_API_KEY"),
|
||||
}
|
||||
|
||||
const state = App.state("provider", async () => {
|
||||
const config = await Config.get()
|
||||
const database: Record<string, Provider.Info> = await ModelsDev.get()
|
||||
|
||||
const providers: {
|
||||
[providerID: string]: {
|
||||
source: Source
|
||||
info: Provider.Info
|
||||
options: Record<string, any>
|
||||
}
|
||||
} = {}
|
||||
const models = new Map<string, { info: Model; language: LanguageModel }>()
|
||||
const sdk = new Map<string, SDK>()
|
||||
|
||||
log.info("loading")
|
||||
|
||||
function mergeProvider(
|
||||
id: string,
|
||||
options: Record<string, any>,
|
||||
source: Source,
|
||||
) {
|
||||
const provider = providers[id]
|
||||
if (!provider) {
|
||||
providers[id] = {
|
||||
source,
|
||||
info: database[id] ?? {
|
||||
id,
|
||||
name: id,
|
||||
models: [],
|
||||
},
|
||||
options,
|
||||
}
|
||||
return
|
||||
}
|
||||
provider.options = mergeDeep(provider.options, options)
|
||||
provider.source = source
|
||||
}
|
||||
|
||||
for (const [providerID, fn] of Object.entries(AUTODETECT)) {
|
||||
const provider = database[providerID]
|
||||
if (!provider) continue
|
||||
const result = await fn(provider)
|
||||
if (!result) continue
|
||||
mergeProvider(providerID, result.options, result.source)
|
||||
}
|
||||
|
||||
for (const [providerID, info] of Object.entries(await Auth.all())) {
|
||||
if (info.type === "api") {
|
||||
mergeProvider(providerID, { apiKey: info.key }, "api")
|
||||
}
|
||||
}
|
||||
|
||||
for (const [providerID, options] of Object.entries(config.provider ?? {})) {
|
||||
mergeProvider(providerID, options, "config")
|
||||
}
|
||||
|
||||
for (const providerID of Object.keys(providers)) {
|
||||
log.info("loaded", { providerID })
|
||||
}
|
||||
|
||||
return {
|
||||
models,
|
||||
providers,
|
||||
sdk,
|
||||
}
|
||||
})
|
||||
|
||||
export async function list() {
|
||||
return state().then((state) => state.providers)
|
||||
}
|
||||
|
||||
async function getSDK(providerID: string) {
|
||||
return (async () => {
|
||||
const s = await state()
|
||||
const existing = s.sdk.get(providerID)
|
||||
if (existing) return existing
|
||||
const dir = path.join(
|
||||
Global.Path.cache,
|
||||
`node_modules`,
|
||||
`@ai-sdk`,
|
||||
providerID,
|
||||
)
|
||||
if (!(await Bun.file(path.join(dir, "package.json")).exists())) {
|
||||
log.info("installing", {
|
||||
providerID,
|
||||
})
|
||||
await BunProc.run(["add", `@ai-sdk/${providerID}@alpha`], {
|
||||
cwd: Global.Path.cache,
|
||||
})
|
||||
}
|
||||
const mod = await import(path.join(dir))
|
||||
const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!]
|
||||
const loaded = fn(s.providers[providerID]?.options)
|
||||
s.sdk.set(providerID, loaded)
|
||||
return loaded as SDK
|
||||
})().catch((e) => {
|
||||
throw new InitError({ providerID: providerID }, { cause: e })
|
||||
})
|
||||
}
|
||||
|
||||
export async function getModel(providerID: string, modelID: string) {
|
||||
const key = `${providerID}/${modelID}`
|
||||
const s = await state()
|
||||
if (s.models.has(key)) return s.models.get(key)!
|
||||
|
||||
log.info("loading", {
|
||||
providerID,
|
||||
modelID,
|
||||
})
|
||||
|
||||
const provider = s.providers[providerID]
|
||||
if (!provider) throw new ModelNotFoundError({ providerID, modelID })
|
||||
const info = provider.info.models[modelID]
|
||||
if (!info) throw new ModelNotFoundError({ providerID, modelID })
|
||||
|
||||
const sdk = await getSDK(providerID)
|
||||
|
||||
try {
|
||||
const language =
|
||||
// @ts-expect-error
|
||||
"responses" in sdk ? sdk.responses(modelID) : sdk.languageModel(modelID)
|
||||
log.info("found", { providerID, modelID })
|
||||
s.models.set(key, {
|
||||
info,
|
||||
language,
|
||||
})
|
||||
return {
|
||||
info,
|
||||
language,
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof NoSuchModelError)
|
||||
throw new ModelNotFoundError(
|
||||
{
|
||||
modelID: modelID,
|
||||
providerID,
|
||||
},
|
||||
{ cause: e },
|
||||
)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
const priority = ["gemini-2.5-pro-preview", "codex-mini", "claude-sonnet-4"]
|
||||
export function sort(models: Model[]) {
|
||||
return sortBy(
|
||||
models,
|
||||
[
|
||||
(model) => priority.findIndex((filter) => model.id.includes(filter)),
|
||||
"desc",
|
||||
],
|
||||
[(model) => (model.id.includes("latest") ? 0 : 1), "asc"],
|
||||
[(model) => model.id, "desc"],
|
||||
)
|
||||
}
|
||||
|
||||
export async function defaultModel() {
|
||||
const [provider] = await list().then((val) => Object.values(val))
|
||||
if (!provider) throw new Error("no providers found")
|
||||
const [model] = sort(Object.values(provider.info.models))
|
||||
if (!model) throw new Error("no models found")
|
||||
return {
|
||||
providerID: provider.info.id,
|
||||
modelID: model.id,
|
||||
}
|
||||
}
|
||||
|
||||
const TOOLS = [
|
||||
BashTool,
|
||||
EditTool,
|
||||
WebFetchTool,
|
||||
GlobTool,
|
||||
GrepTool,
|
||||
ListTool,
|
||||
LspDiagnosticTool,
|
||||
LspHoverTool,
|
||||
PatchTool,
|
||||
ReadTool,
|
||||
EditTool,
|
||||
// MultiEditTool,
|
||||
WriteTool,
|
||||
TodoWriteTool,
|
||||
TodoReadTool,
|
||||
]
|
||||
const TOOL_MAPPING: Record<string, Tool.Info[]> = {
|
||||
anthropic: TOOLS.filter((t) => t.id !== "opencode.patch"),
|
||||
openai: TOOLS,
|
||||
google: TOOLS,
|
||||
}
|
||||
export async function tools(providerID: string) {
|
||||
const cfg = await Config.get()
|
||||
if (cfg.tool?.provider?.[providerID])
|
||||
return cfg.tool.provider[providerID].map(
|
||||
(id) => TOOLS.find((t) => t.id === id)!,
|
||||
)
|
||||
return TOOL_MAPPING[providerID] ?? TOOLS
|
||||
}
|
||||
|
||||
export const ModelNotFoundError = NamedError.create(
|
||||
"ProviderModelNotFoundError",
|
||||
z.object({
|
||||
providerID: z.string(),
|
||||
modelID: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
export const InitError = NamedError.create(
|
||||
"ProviderInitError",
|
||||
z.object({
|
||||
providerID: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
export const AuthError = NamedError.create(
|
||||
"ProviderAuthError",
|
||||
z.object({
|
||||
providerID: z.string(),
|
||||
message: z.string(),
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
import { Log } from "../util/log"
|
||||
import { Bus } from "../bus"
|
||||
import { describeRoute, generateSpecs, openAPISpecs } from "hono-openapi"
|
||||
import { Hono } from "hono"
|
||||
import { streamSSE } from "hono/streaming"
|
||||
import { Session } from "../session"
|
||||
import { resolver, validator as zValidator } from "hono-openapi/zod"
|
||||
import { z } from "zod"
|
||||
import { Message } from "../session/message"
|
||||
import { Provider } from "../provider/provider"
|
||||
import { App } from "../app/app"
|
||||
import { Global } from "../global"
|
||||
import { mapValues } from "remeda"
|
||||
import { NamedError } from "../util/error"
|
||||
import { Fzf } from "../external/fzf"
|
||||
|
||||
const ERRORS = {
|
||||
400: {
|
||||
description: "Bad request",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
z
|
||||
.object({
|
||||
data: z.record(z.string(), z.any()),
|
||||
})
|
||||
.openapi({
|
||||
ref: "Error",
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
export namespace Server {
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
export type Routes = ReturnType<typeof app>
|
||||
|
||||
function app() {
|
||||
const app = new Hono()
|
||||
|
||||
const result = app
|
||||
.onError((err, c) => {
|
||||
if (err instanceof NamedError) {
|
||||
return c.json(err.toObject(), {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
return c.json(
|
||||
new NamedError.Unknown({ message: err.toString() }).toObject(),
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
)
|
||||
})
|
||||
.use((c, next) => {
|
||||
log.info("request", {
|
||||
method: c.req.method,
|
||||
path: c.req.path,
|
||||
})
|
||||
return next()
|
||||
})
|
||||
.get(
|
||||
"/openapi",
|
||||
openAPISpecs(app, {
|
||||
documentation: {
|
||||
info: {
|
||||
title: "opencode",
|
||||
version: "1.0.0",
|
||||
description: "opencode api",
|
||||
},
|
||||
openapi: "3.0.0",
|
||||
},
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/event",
|
||||
describeRoute({
|
||||
description: "Get events",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Event stream",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
Bus.payloads().openapi({
|
||||
ref: "Event",
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
log.info("event connected")
|
||||
return streamSSE(c, async (stream) => {
|
||||
stream.writeSSE({
|
||||
data: JSON.stringify({}),
|
||||
})
|
||||
const unsub = Bus.subscribeAll(async (event) => {
|
||||
await stream.writeSSE({
|
||||
data: JSON.stringify(event),
|
||||
})
|
||||
})
|
||||
await new Promise<void>((resolve) => {
|
||||
stream.onAbort(() => {
|
||||
unsub()
|
||||
resolve()
|
||||
log.info("event disconnected")
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/app_info",
|
||||
describeRoute({
|
||||
description: "Get app info",
|
||||
responses: {
|
||||
200: {
|
||||
description: "200",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(App.Info),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(App.info())
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/app_initialize",
|
||||
describeRoute({
|
||||
description: "Initialize the app",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Initialize the app",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
await App.initialize()
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/session_initialize",
|
||||
describeRoute({
|
||||
description: "Analyze the app and create an AGENTS.md file",
|
||||
responses: {
|
||||
200: {
|
||||
description: "200",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
zValidator(
|
||||
"json",
|
||||
z.object({
|
||||
sessionID: z.string(),
|
||||
providerID: z.string(),
|
||||
modelID: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
await Session.initialize(body)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/path_get",
|
||||
describeRoute({
|
||||
description: "Get paths",
|
||||
responses: {
|
||||
200: {
|
||||
description: "200",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
z.object({
|
||||
root: z.string(),
|
||||
data: z.string(),
|
||||
cwd: z.string(),
|
||||
config: z.string(),
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const app = App.info()
|
||||
return c.json({
|
||||
root: app.path.root,
|
||||
data: app.path.data,
|
||||
cwd: app.path.cwd,
|
||||
config: Global.Path.data,
|
||||
})
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/session_create",
|
||||
describeRoute({
|
||||
description: "Create a new session",
|
||||
responses: {
|
||||
...ERRORS,
|
||||
200: {
|
||||
description: "Successfully created session",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Session.Info),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const session = await Session.create()
|
||||
return c.json(session)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/session_share",
|
||||
describeRoute({
|
||||
description: "Share the session",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successfully shared session",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Session.Info),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
zValidator(
|
||||
"json",
|
||||
z.object({
|
||||
sessionID: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
await Session.share(body.sessionID)
|
||||
const session = await Session.get(body.sessionID)
|
||||
return c.json(session)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/session_messages",
|
||||
describeRoute({
|
||||
description: "Get messages for a session",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successfully created session",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Message.Info.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
zValidator(
|
||||
"json",
|
||||
z.object({
|
||||
sessionID: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const messages = await Session.messages(c.req.valid("json").sessionID)
|
||||
return c.json(messages)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/session_list",
|
||||
describeRoute({
|
||||
description: "List all sessions",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of sessions",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Session.Info.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const sessions = await Array.fromAsync(Session.list())
|
||||
return c.json(sessions)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/session_abort",
|
||||
describeRoute({
|
||||
description: "Abort a session",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Aborted session",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
zValidator(
|
||||
"json",
|
||||
z.object({
|
||||
sessionID: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
return c.json(Session.abort(body.sessionID))
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/session_summarize",
|
||||
describeRoute({
|
||||
description: "Summarize the session",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Summarize the session",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
zValidator(
|
||||
"json",
|
||||
z.object({
|
||||
sessionID: z.string(),
|
||||
providerID: z.string(),
|
||||
modelID: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
await Session.summarize(body)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/session_chat",
|
||||
describeRoute({
|
||||
description: "Chat with a model",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Chat with a model",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Message.Info),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
zValidator(
|
||||
"json",
|
||||
z.object({
|
||||
sessionID: z.string(),
|
||||
providerID: z.string(),
|
||||
modelID: z.string(),
|
||||
parts: Message.Part.array(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
const msg = await Session.chat(body)
|
||||
return c.json(msg)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/provider_list",
|
||||
describeRoute({
|
||||
description: "List all providers",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of providers",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
z.object({
|
||||
providers: Provider.Info.array(),
|
||||
default: z.record(z.string(), z.string()),
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const providers = await Provider.list().then((x) =>
|
||||
mapValues(x, (item) => item.info),
|
||||
)
|
||||
return c.json({
|
||||
providers: Object.values(providers),
|
||||
defaults: mapValues(
|
||||
providers,
|
||||
(item) => Provider.sort(Object.values(item.models))[0].id,
|
||||
),
|
||||
})
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/file_search",
|
||||
describeRoute({
|
||||
description: "Search for files",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Search for files",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.string().array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
zValidator(
|
||||
"json",
|
||||
z.object({
|
||||
query: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
const app = App.info()
|
||||
const result = await Fzf.search(app.path.cwd, body.query)
|
||||
return c.json(result)
|
||||
},
|
||||
)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export async function openapi() {
|
||||
const a = app()
|
||||
const result = await generateSpecs(a, {
|
||||
documentation: {
|
||||
info: {
|
||||
title: "opencode",
|
||||
version: "1.0.0",
|
||||
description: "opencode api",
|
||||
},
|
||||
openapi: "3.0.0",
|
||||
},
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
export function listen() {
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
hostname: "0.0.0.0",
|
||||
idleTimeout: 0,
|
||||
fetch: app().fetch,
|
||||
})
|
||||
return server
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { App } from "../app/app"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
|
||||
export namespace SessionContext {
|
||||
const FILES = [
|
||||
"AGENTS.md",
|
||||
"CLAUDE.md",
|
||||
"CONTEXT.md", // deprecated
|
||||
]
|
||||
export async function find() {
|
||||
const { cwd, root } = App.info().path
|
||||
const found = []
|
||||
for (const item of FILES) {
|
||||
const matches = await Filesystem.findUp(item, cwd, root)
|
||||
found.push(...matches.map((x) => Bun.file(x).text()))
|
||||
}
|
||||
return Promise.all(found).then((parts) => parts.join("\n\n"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,740 @@
|
||||
import path from "path"
|
||||
import { App } from "../app/app"
|
||||
import { Identifier } from "../id/id"
|
||||
import { Storage } from "../storage/storage"
|
||||
import { Log } from "../util/log"
|
||||
import {
|
||||
convertToModelMessages,
|
||||
generateText,
|
||||
LoadAPIKeyError,
|
||||
stepCountIs,
|
||||
streamText,
|
||||
tool,
|
||||
type Tool as AITool,
|
||||
type LanguageModelUsage,
|
||||
} from "ai"
|
||||
import { z, ZodSchema } from "zod"
|
||||
import { Decimal } from "decimal.js"
|
||||
|
||||
import PROMPT_ANTHROPIC from "./prompt/anthropic.txt"
|
||||
import PROMPT_ANTHROPIC_SPOOF from "./prompt/anthropic_spoof.txt"
|
||||
import PROMPT_TITLE from "./prompt/title.txt"
|
||||
import PROMPT_SUMMARIZE from "./prompt/summarize.txt"
|
||||
import PROMPT_INITIALIZE from "../session/prompt/initialize.txt"
|
||||
|
||||
import { Share } from "../share/share"
|
||||
import { Message } from "./message"
|
||||
import { Bus } from "../bus"
|
||||
import { Provider } from "../provider/provider"
|
||||
import { SessionContext } from "./context"
|
||||
import { ListTool } from "../tool/ls"
|
||||
import { MCP } from "../mcp"
|
||||
import { NamedError } from "../util/error"
|
||||
|
||||
export namespace Session {
|
||||
const log = Log.create({ service: "session" })
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
id: Identifier.schema("session"),
|
||||
share: z
|
||||
.object({
|
||||
secret: z.string(),
|
||||
url: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
title: z.string(),
|
||||
time: z.object({
|
||||
created: z.number(),
|
||||
updated: z.number(),
|
||||
}),
|
||||
})
|
||||
.openapi({
|
||||
ref: "session.info",
|
||||
})
|
||||
export type Info = z.output<typeof Info>
|
||||
|
||||
export const Event = {
|
||||
Updated: Bus.event(
|
||||
"session.updated",
|
||||
z.object({
|
||||
info: Info,
|
||||
}),
|
||||
),
|
||||
Error: Bus.event(
|
||||
"session.error",
|
||||
z.object({
|
||||
error: Message.Info.shape.metadata.shape.error,
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
const state = App.state("session", () => {
|
||||
const sessions = new Map<string, Info>()
|
||||
const messages = new Map<string, Message.Info[]>()
|
||||
|
||||
return {
|
||||
sessions,
|
||||
messages,
|
||||
}
|
||||
})
|
||||
|
||||
export async function create() {
|
||||
const result: Info = {
|
||||
id: Identifier.descending("session"),
|
||||
title: "New Session - " + new Date().toISOString(),
|
||||
time: {
|
||||
created: Date.now(),
|
||||
updated: Date.now(),
|
||||
},
|
||||
}
|
||||
log.info("created", result)
|
||||
state().sessions.set(result.id, result)
|
||||
await Storage.writeJSON("session/info/" + result.id, result)
|
||||
share(result.id).then((share) => {
|
||||
update(result.id, (draft) => {
|
||||
draft.share = share
|
||||
})
|
||||
})
|
||||
Bus.publish(Event.Updated, {
|
||||
info: result,
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
export async function get(id: string) {
|
||||
const result = state().sessions.get(id)
|
||||
if (result) {
|
||||
return result
|
||||
}
|
||||
const read = await Storage.readJSON<Info>("session/info/" + id)
|
||||
state().sessions.set(id, read)
|
||||
return read as Info
|
||||
}
|
||||
|
||||
export async function share(id: string) {
|
||||
const session = await get(id)
|
||||
if (session.share) return session.share
|
||||
const share = await Share.create(id)
|
||||
await update(id, (draft) => {
|
||||
draft.share = share
|
||||
})
|
||||
for (const msg of await messages(id)) {
|
||||
await Share.sync("session/message/" + id + "/" + msg.id, msg)
|
||||
}
|
||||
return share
|
||||
}
|
||||
|
||||
export async function update(id: string, editor: (session: Info) => void) {
|
||||
const { sessions } = state()
|
||||
const session = await get(id)
|
||||
if (!session) return
|
||||
editor(session)
|
||||
session.time.updated = Date.now()
|
||||
sessions.set(id, session)
|
||||
await Storage.writeJSON("session/info/" + id, session)
|
||||
Bus.publish(Event.Updated, {
|
||||
info: session,
|
||||
})
|
||||
return session
|
||||
}
|
||||
|
||||
export async function messages(sessionID: string) {
|
||||
const result = [] as Message.Info[]
|
||||
const list = Storage.list("session/message/" + sessionID)
|
||||
for await (const p of list) {
|
||||
const read = await Storage.readJSON<Message.Info>(p)
|
||||
result.push(read)
|
||||
}
|
||||
result.sort((a, b) => (a.id > b.id ? 1 : -1))
|
||||
return result
|
||||
}
|
||||
|
||||
export async function getMessage(sessionID: string, messageID: string) {
|
||||
return Storage.readJSON<Message.Info>(
|
||||
"session/message/" + sessionID + "/" + messageID,
|
||||
)
|
||||
}
|
||||
|
||||
export async function* list() {
|
||||
for await (const item of Storage.list("session/info")) {
|
||||
const sessionID = path.basename(item, ".json")
|
||||
yield get(sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
export function abort(sessionID: string) {
|
||||
const controller = pending.get(sessionID)
|
||||
if (!controller) return false
|
||||
controller.abort()
|
||||
pending.delete(sessionID)
|
||||
return true
|
||||
}
|
||||
|
||||
async function updateMessage(msg: Message.Info) {
|
||||
await Storage.writeJSON(
|
||||
"session/message/" + msg.metadata.sessionID + "/" + msg.id,
|
||||
msg,
|
||||
)
|
||||
Bus.publish(Message.Event.Updated, {
|
||||
info: msg,
|
||||
})
|
||||
}
|
||||
|
||||
export async function chat(input: {
|
||||
sessionID: string
|
||||
providerID: string
|
||||
modelID: string
|
||||
parts: Message.Part[]
|
||||
}) {
|
||||
const l = log.clone().tag("session", input.sessionID)
|
||||
l.info("chatting")
|
||||
const model = await Provider.getModel(input.providerID, input.modelID)
|
||||
let msgs = await messages(input.sessionID)
|
||||
const previous = msgs.at(-1)
|
||||
if (previous?.metadata.assistant) {
|
||||
const tokens =
|
||||
previous.metadata.assistant.tokens.input +
|
||||
previous.metadata.assistant.tokens.output
|
||||
if (
|
||||
tokens >
|
||||
(model.info.limit.context - (model.info.limit.output ?? 0)) * 0.9
|
||||
) {
|
||||
await summarize({
|
||||
sessionID: input.sessionID,
|
||||
providerID: input.providerID,
|
||||
modelID: input.modelID,
|
||||
})
|
||||
return chat(input)
|
||||
}
|
||||
}
|
||||
|
||||
using abort = lock(input.sessionID)
|
||||
|
||||
const lastSummary = msgs.findLast(
|
||||
(msg) => msg.metadata.assistant?.summary === true,
|
||||
)
|
||||
if (lastSummary)
|
||||
msgs = msgs.filter(
|
||||
(msg) => msg.role === "system" || msg.id >= lastSummary.id,
|
||||
)
|
||||
|
||||
if (msgs.length === 0) {
|
||||
const app = App.info()
|
||||
if (input.providerID === "anthropic") {
|
||||
const claude: Message.Info = {
|
||||
id: Identifier.ascending("message"),
|
||||
role: "system",
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: PROMPT_ANTHROPIC_SPOOF.trim(),
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
sessionID: input.sessionID,
|
||||
time: {
|
||||
created: Date.now(),
|
||||
},
|
||||
tool: {},
|
||||
},
|
||||
}
|
||||
await updateMessage(claude)
|
||||
msgs.push(claude)
|
||||
}
|
||||
const system: Message.Info = {
|
||||
id: Identifier.ascending("message"),
|
||||
role: "system",
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: PROMPT_ANTHROPIC,
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: [
|
||||
`Here is some useful information about the environment you are running in:`,
|
||||
`<env>`,
|
||||
`Working directory: ${app.path.cwd}`,
|
||||
`Is directory a git repo: ${app.git ? "yes" : "no"}`,
|
||||
`Platform: ${process.platform}`,
|
||||
`Today's date: ${new Date().toISOString()}`,
|
||||
`</env>`,
|
||||
`<project>`,
|
||||
`${app.git ? await ListTool.execute({ path: app.path.cwd, ignore: [] }, { sessionID: input.sessionID, abort: abort.signal }).then((x) => x.output) : ""}`,
|
||||
`</project>`,
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
sessionID: input.sessionID,
|
||||
time: {
|
||||
created: Date.now(),
|
||||
},
|
||||
tool: {},
|
||||
},
|
||||
}
|
||||
const context = await SessionContext.find()
|
||||
if (context) {
|
||||
system.parts.push({
|
||||
type: "text",
|
||||
text: context,
|
||||
})
|
||||
}
|
||||
msgs.push(system)
|
||||
generateText({
|
||||
maxOutputTokens: 20,
|
||||
messages: convertToModelMessages([
|
||||
{
|
||||
role: "system",
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: PROMPT_ANTHROPIC_SPOOF.trim(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "system",
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: PROMPT_TITLE,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
parts: input.parts,
|
||||
},
|
||||
]),
|
||||
temperature: 0,
|
||||
model: model.language,
|
||||
})
|
||||
.then((result) => {
|
||||
return Session.update(input.sessionID, (draft) => {
|
||||
draft.title = result.text
|
||||
})
|
||||
})
|
||||
.catch(() => {})
|
||||
await updateMessage(system)
|
||||
}
|
||||
const msg: Message.Info = {
|
||||
role: "user",
|
||||
id: Identifier.ascending("message"),
|
||||
parts: input.parts,
|
||||
metadata: {
|
||||
time: {
|
||||
created: Date.now(),
|
||||
},
|
||||
sessionID: input.sessionID,
|
||||
tool: {},
|
||||
},
|
||||
}
|
||||
await updateMessage(msg)
|
||||
msgs.push(msg)
|
||||
|
||||
const next: Message.Info = {
|
||||
id: Identifier.ascending("message"),
|
||||
role: "assistant",
|
||||
parts: [],
|
||||
metadata: {
|
||||
assistant: {
|
||||
cost: 0,
|
||||
tokens: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
reasoning: 0,
|
||||
},
|
||||
modelID: input.modelID,
|
||||
providerID: input.providerID,
|
||||
},
|
||||
time: {
|
||||
created: Date.now(),
|
||||
},
|
||||
sessionID: input.sessionID,
|
||||
tool: {},
|
||||
},
|
||||
}
|
||||
await updateMessage(next)
|
||||
const tools: Record<string, AITool> = {}
|
||||
for (const item of await Provider.tools(input.providerID)) {
|
||||
tools[item.id.replaceAll(".", "_")] = tool({
|
||||
id: item.id as any,
|
||||
description: item.description,
|
||||
parameters: item.parameters as ZodSchema,
|
||||
async execute(args, opts) {
|
||||
const start = Date.now()
|
||||
try {
|
||||
const result = await item.execute(args, {
|
||||
sessionID: input.sessionID,
|
||||
abort: abort.signal,
|
||||
})
|
||||
next.metadata!.tool![opts.toolCallId] = {
|
||||
...result.metadata,
|
||||
time: {
|
||||
start,
|
||||
end: Date.now(),
|
||||
},
|
||||
}
|
||||
await updateMessage(next)
|
||||
return result.output
|
||||
} catch (e: any) {
|
||||
next.metadata!.tool![opts.toolCallId] = {
|
||||
error: true,
|
||||
message: e.toString(),
|
||||
title: e.toString(),
|
||||
time: {
|
||||
start,
|
||||
end: Date.now(),
|
||||
},
|
||||
}
|
||||
await updateMessage(next)
|
||||
return e.toString()
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
for (const [key, item] of Object.entries(await MCP.tools())) {
|
||||
const execute = item.execute
|
||||
if (!execute) continue
|
||||
item.execute = async (args, opts) => {
|
||||
const start = Date.now()
|
||||
try {
|
||||
const result = await execute(args, opts)
|
||||
next.metadata!.tool![opts.toolCallId] = {
|
||||
...result.metadata,
|
||||
time: {
|
||||
start,
|
||||
end: Date.now(),
|
||||
},
|
||||
}
|
||||
await updateMessage(next)
|
||||
return result.content
|
||||
.filter((x: any) => x.type === "text")
|
||||
.map((x: any) => x.text)
|
||||
.join("\n\n")
|
||||
} catch (e: any) {
|
||||
next.metadata!.tool![opts.toolCallId] = {
|
||||
error: true,
|
||||
message: e.toString(),
|
||||
title: "mcp",
|
||||
time: {
|
||||
start,
|
||||
end: Date.now(),
|
||||
},
|
||||
}
|
||||
await updateMessage(next)
|
||||
return e.toString()
|
||||
}
|
||||
}
|
||||
tools[key] = item
|
||||
}
|
||||
|
||||
let text: Message.TextPart | undefined
|
||||
const result = streamText({
|
||||
onStepFinish: async (step) => {
|
||||
log.info("step finish", {
|
||||
finishReason: step.finishReason,
|
||||
})
|
||||
const assistant = next.metadata!.assistant!
|
||||
const usage = getUsage(step.usage, model.info)
|
||||
assistant.cost += usage.cost
|
||||
assistant.tokens = usage.tokens
|
||||
await updateMessage(next)
|
||||
if (text) {
|
||||
Bus.publish(Message.Event.PartUpdated, {
|
||||
part: text,
|
||||
messageID: next.id,
|
||||
sessionID: next.metadata.sessionID,
|
||||
})
|
||||
}
|
||||
text = undefined
|
||||
},
|
||||
async onChunk(input) {
|
||||
const value = input.chunk
|
||||
l.info("part", {
|
||||
type: value.type,
|
||||
})
|
||||
switch (value.type) {
|
||||
case "text":
|
||||
if (!text) {
|
||||
text = value
|
||||
next.parts.push(value)
|
||||
break
|
||||
} else text.text += value.text
|
||||
break
|
||||
|
||||
case "tool-call": {
|
||||
const [match] = next.parts.flatMap((p) =>
|
||||
p.type === "tool-invocation" &&
|
||||
p.toolInvocation.toolCallId === value.toolCallId
|
||||
? [p]
|
||||
: [],
|
||||
)
|
||||
if (!match) break
|
||||
match.toolInvocation.args = value.args
|
||||
match.toolInvocation.state = "call"
|
||||
Bus.publish(Message.Event.PartUpdated, {
|
||||
part: match,
|
||||
messageID: next.id,
|
||||
sessionID: next.metadata.sessionID,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case "tool-call-streaming-start":
|
||||
next.parts.push({
|
||||
type: "tool-invocation",
|
||||
toolInvocation: {
|
||||
state: "partial-call",
|
||||
toolName: value.toolName,
|
||||
toolCallId: value.toolCallId,
|
||||
args: {},
|
||||
},
|
||||
})
|
||||
Bus.publish(Message.Event.PartUpdated, {
|
||||
part: next.parts[next.parts.length - 1],
|
||||
messageID: next.id,
|
||||
sessionID: next.metadata.sessionID,
|
||||
})
|
||||
break
|
||||
|
||||
case "tool-call-delta":
|
||||
break
|
||||
|
||||
case "tool-result":
|
||||
const match = next.parts.find(
|
||||
(p) =>
|
||||
p.type === "tool-invocation" &&
|
||||
p.toolInvocation.toolCallId === value.toolCallId,
|
||||
)
|
||||
if (match && match.type === "tool-invocation") {
|
||||
match.toolInvocation = {
|
||||
args: value.args,
|
||||
toolCallId: value.toolCallId,
|
||||
toolName: value.toolName,
|
||||
state: "result",
|
||||
result: value.result as string,
|
||||
}
|
||||
Bus.publish(Message.Event.PartUpdated, {
|
||||
part: match,
|
||||
messageID: next.id,
|
||||
sessionID: next.metadata.sessionID,
|
||||
})
|
||||
}
|
||||
break
|
||||
|
||||
default:
|
||||
l.info("unhandled", {
|
||||
type: value.type,
|
||||
})
|
||||
}
|
||||
await updateMessage(next)
|
||||
},
|
||||
async onFinish(input) {
|
||||
const assistant = next.metadata!.assistant!
|
||||
const usage = getUsage(input.totalUsage, model.info)
|
||||
assistant.cost = usage.cost
|
||||
await updateMessage(next)
|
||||
},
|
||||
onError(err) {
|
||||
log.error("error", err)
|
||||
switch (true) {
|
||||
case LoadAPIKeyError.isInstance(err.error):
|
||||
next.metadata.error = new Provider.AuthError(
|
||||
{
|
||||
providerID: input.providerID,
|
||||
message: err.error.message,
|
||||
},
|
||||
{ cause: err.error },
|
||||
).toObject()
|
||||
break
|
||||
case err.error instanceof Error:
|
||||
next.metadata.error = new NamedError.Unknown(
|
||||
{ message: err.error.toString() },
|
||||
{ cause: err.error },
|
||||
).toObject()
|
||||
break
|
||||
default:
|
||||
next.metadata.error = new NamedError.Unknown(
|
||||
{ message: JSON.stringify(err.error) },
|
||||
{ cause: err.error },
|
||||
)
|
||||
}
|
||||
Bus.publish(Event.Error, {
|
||||
error: next.metadata.error,
|
||||
})
|
||||
},
|
||||
async prepareStep(step) {
|
||||
next.parts.push({
|
||||
type: "step-start",
|
||||
})
|
||||
await updateMessage(next)
|
||||
return step
|
||||
},
|
||||
toolCallStreaming: true,
|
||||
abortSignal: abort.signal,
|
||||
stopWhen: stepCountIs(1000),
|
||||
messages: convertToModelMessages(msgs),
|
||||
temperature: model.info.id === "codex-mini-latest" ? undefined : 0,
|
||||
tools: {
|
||||
...(await MCP.tools()),
|
||||
...tools,
|
||||
},
|
||||
model: model.language,
|
||||
})
|
||||
await result.consumeStream({
|
||||
onError: (err) => {
|
||||
log.error("stream error", {
|
||||
err,
|
||||
})
|
||||
},
|
||||
})
|
||||
next.metadata!.time.completed = Date.now()
|
||||
for (const part of next.parts) {
|
||||
if (
|
||||
part.type === "tool-invocation" &&
|
||||
part.toolInvocation.state !== "result"
|
||||
) {
|
||||
part.toolInvocation = {
|
||||
...part.toolInvocation,
|
||||
state: "result",
|
||||
result: "request was aborted",
|
||||
}
|
||||
}
|
||||
}
|
||||
await updateMessage(next)
|
||||
return next
|
||||
}
|
||||
|
||||
export async function summarize(input: {
|
||||
sessionID: string
|
||||
providerID: string
|
||||
modelID: string
|
||||
}) {
|
||||
using abort = lock(input.sessionID)
|
||||
const msgs = await messages(input.sessionID)
|
||||
const lastSummary = msgs.findLast(
|
||||
(msg) => msg.metadata.assistant?.summary === true,
|
||||
)?.id
|
||||
const filtered = msgs.filter(
|
||||
(msg) => msg.role !== "system" && (!lastSummary || msg.id >= lastSummary),
|
||||
)
|
||||
const model = await Provider.getModel(input.providerID, input.modelID)
|
||||
const next: Message.Info = {
|
||||
id: Identifier.ascending("message"),
|
||||
role: "assistant",
|
||||
parts: [],
|
||||
metadata: {
|
||||
tool: {},
|
||||
sessionID: input.sessionID,
|
||||
assistant: {
|
||||
summary: true,
|
||||
cost: 0,
|
||||
modelID: input.modelID,
|
||||
providerID: input.providerID,
|
||||
tokens: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
reasoning: 0,
|
||||
},
|
||||
},
|
||||
time: {
|
||||
created: Date.now(),
|
||||
},
|
||||
},
|
||||
}
|
||||
await updateMessage(next)
|
||||
const result = await generateText({
|
||||
abortSignal: abort.signal,
|
||||
model: model.language,
|
||||
messages: convertToModelMessages([
|
||||
{
|
||||
role: "system",
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: PROMPT_SUMMARIZE,
|
||||
},
|
||||
],
|
||||
},
|
||||
...filtered,
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Provide a detailed but concise summary of our conversation above. Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next.",
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
})
|
||||
next.parts.push({
|
||||
type: "text",
|
||||
text: result.text,
|
||||
})
|
||||
const assistant = next.metadata!.assistant!
|
||||
const usage = getUsage(result.usage, model.info)
|
||||
assistant.cost = usage.cost
|
||||
assistant.tokens = usage.tokens
|
||||
await updateMessage(next)
|
||||
}
|
||||
|
||||
const pending = new Map<string, AbortController>()
|
||||
function lock(sessionID: string) {
|
||||
log.info("locking", { sessionID })
|
||||
if (pending.has(sessionID)) throw new BusyError(sessionID)
|
||||
const controller = new AbortController()
|
||||
pending.set(sessionID, controller)
|
||||
return {
|
||||
signal: controller.signal,
|
||||
[Symbol.dispose]() {
|
||||
log.info("unlocking", { sessionID })
|
||||
pending.delete(sessionID)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function getUsage(usage: LanguageModelUsage, model: Provider.Model) {
|
||||
const tokens = {
|
||||
input: usage.inputTokens ?? 0,
|
||||
output: usage.outputTokens ?? 0,
|
||||
reasoning: usage.reasoningTokens ?? 0,
|
||||
}
|
||||
return {
|
||||
cost: new Decimal(0)
|
||||
.add(new Decimal(tokens.input).mul(model.cost.input).div(1_000_000))
|
||||
.add(new Decimal(tokens.output).mul(model.cost.output).div(1_000_000))
|
||||
.toNumber(),
|
||||
tokens,
|
||||
}
|
||||
}
|
||||
|
||||
export class BusyError extends Error {
|
||||
constructor(public readonly sessionID: string) {
|
||||
super(`Session ${sessionID} is busy`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function initialize(input: {
|
||||
sessionID: string
|
||||
modelID: string
|
||||
providerID: string
|
||||
}) {
|
||||
const app = App.info()
|
||||
await Session.chat({
|
||||
sessionID: input.sessionID,
|
||||
providerID: input.providerID,
|
||||
modelID: input.modelID,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: PROMPT_INITIALIZE.replace("${path}", app.path.root),
|
||||
},
|
||||
],
|
||||
})
|
||||
await App.initialize()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import z from "zod"
|
||||
import { Bus } from "../bus"
|
||||
import { Provider } from "../provider/provider"
|
||||
import { NamedError } from "../util/error"
|
||||
|
||||
export namespace Message {
|
||||
export const ToolCall = z
|
||||
.object({
|
||||
state: z.literal("call"),
|
||||
step: z.number().optional(),
|
||||
toolCallId: z.string(),
|
||||
toolName: z.string(),
|
||||
args: z.custom<Required<unknown>>(),
|
||||
})
|
||||
.openapi({
|
||||
ref: "Message.ToolInvocation.ToolCall",
|
||||
})
|
||||
export type ToolCall = z.infer<typeof ToolCall>
|
||||
|
||||
export const ToolPartialCall = z
|
||||
.object({
|
||||
state: z.literal("partial-call"),
|
||||
step: z.number().optional(),
|
||||
toolCallId: z.string(),
|
||||
toolName: z.string(),
|
||||
args: z.custom<Required<unknown>>(),
|
||||
})
|
||||
.openapi({
|
||||
ref: "Message.ToolInvocation.ToolPartialCall",
|
||||
})
|
||||
export type ToolPartialCall = z.infer<typeof ToolPartialCall>
|
||||
|
||||
export const ToolResult = z
|
||||
.object({
|
||||
state: z.literal("result"),
|
||||
step: z.number().optional(),
|
||||
toolCallId: z.string(),
|
||||
toolName: z.string(),
|
||||
args: z.custom<Required<unknown>>(),
|
||||
result: z.string(),
|
||||
})
|
||||
.openapi({
|
||||
ref: "Message.ToolInvocation.ToolResult",
|
||||
})
|
||||
export type ToolResult = z.infer<typeof ToolResult>
|
||||
|
||||
export const ToolInvocation = z
|
||||
.discriminatedUnion("state", [ToolCall, ToolPartialCall, ToolResult])
|
||||
.openapi({
|
||||
ref: "Message.ToolInvocation",
|
||||
})
|
||||
export type ToolInvocation = z.infer<typeof ToolInvocation>
|
||||
|
||||
export const TextPart = z
|
||||
.object({
|
||||
type: z.literal("text"),
|
||||
text: z.string(),
|
||||
})
|
||||
.openapi({
|
||||
ref: "Message.Part.Text",
|
||||
})
|
||||
export type TextPart = z.infer<typeof TextPart>
|
||||
|
||||
export const ReasoningPart = z
|
||||
.object({
|
||||
type: z.literal("reasoning"),
|
||||
text: z.string(),
|
||||
providerMetadata: z.record(z.any()).optional(),
|
||||
})
|
||||
.openapi({
|
||||
ref: "Message.Part.Reasoning",
|
||||
})
|
||||
export type ReasoningPart = z.infer<typeof ReasoningPart>
|
||||
|
||||
export const ToolInvocationPart = z
|
||||
.object({
|
||||
type: z.literal("tool-invocation"),
|
||||
toolInvocation: ToolInvocation,
|
||||
})
|
||||
.openapi({
|
||||
ref: "Message.Part.ToolInvocation",
|
||||
})
|
||||
export type ToolInvocationPart = z.infer<typeof ToolInvocationPart>
|
||||
|
||||
export const SourceUrlPart = z
|
||||
.object({
|
||||
type: z.literal("source-url"),
|
||||
sourceId: z.string(),
|
||||
url: z.string(),
|
||||
title: z.string().optional(),
|
||||
providerMetadata: z.record(z.any()).optional(),
|
||||
})
|
||||
.openapi({
|
||||
ref: "Message.Part.SourceUrl",
|
||||
})
|
||||
export type SourceUrlPart = z.infer<typeof SourceUrlPart>
|
||||
|
||||
export const FilePart = z
|
||||
.object({
|
||||
type: z.literal("file"),
|
||||
mediaType: z.string(),
|
||||
filename: z.string().optional(),
|
||||
url: z.string(),
|
||||
})
|
||||
.openapi({
|
||||
ref: "Message.Part.File",
|
||||
})
|
||||
export type FilePart = z.infer<typeof FilePart>
|
||||
|
||||
export const StepStartPart = z
|
||||
.object({
|
||||
type: z.literal("step-start"),
|
||||
})
|
||||
.openapi({
|
||||
ref: "Message.Part.StepStart",
|
||||
})
|
||||
export type StepStartPart = z.infer<typeof StepStartPart>
|
||||
|
||||
export const Part = z
|
||||
.discriminatedUnion("type", [
|
||||
TextPart,
|
||||
ReasoningPart,
|
||||
ToolInvocationPart,
|
||||
SourceUrlPart,
|
||||
FilePart,
|
||||
StepStartPart,
|
||||
])
|
||||
.openapi({
|
||||
ref: "Message.Part",
|
||||
})
|
||||
export type Part = z.infer<typeof Part>
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
role: z.enum(["system", "user", "assistant"]),
|
||||
parts: z.array(Part),
|
||||
metadata: z.object({
|
||||
time: z.object({
|
||||
created: z.number(),
|
||||
completed: z.number().optional(),
|
||||
}),
|
||||
error: z
|
||||
.discriminatedUnion("name", [
|
||||
Provider.AuthError.Schema,
|
||||
NamedError.Unknown.Schema,
|
||||
])
|
||||
.optional(),
|
||||
sessionID: z.string(),
|
||||
tool: z.record(
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
title: z.string(),
|
||||
time: z.object({
|
||||
start: z.number(),
|
||||
end: z.number(),
|
||||
}),
|
||||
})
|
||||
.catchall(z.any()),
|
||||
),
|
||||
assistant: z
|
||||
.object({
|
||||
modelID: z.string(),
|
||||
providerID: z.string(),
|
||||
cost: z.number(),
|
||||
summary: z.boolean().optional(),
|
||||
tokens: z.object({
|
||||
input: z.number(),
|
||||
output: z.number(),
|
||||
reasoning: z.number(),
|
||||
}),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
})
|
||||
.openapi({
|
||||
ref: "Message.Info",
|
||||
})
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
export const Event = {
|
||||
Updated: Bus.event(
|
||||
"message.updated",
|
||||
z.object({
|
||||
info: Info,
|
||||
}),
|
||||
),
|
||||
PartUpdated: Bus.event(
|
||||
"message.part.updated",
|
||||
z.object({ part: Part, sessionID: z.string(), messageID: z.string() }),
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
You are OpenCode, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
|
||||
|
||||
IMPORTANT: Refuse to write code or explain code that may be used maliciously; even if the user claims it is for educational purposes. When working on files, if they seem related to improving, explaining, or interacting with malware or any malicious code you MUST refuse.
|
||||
IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure. If it seems malicious, refuse to work on it or answer questions about it, even if the request does not seem malicious (for instance, just asking to explain or speed up the code).
|
||||
IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files.
|
||||
|
||||
If the user asks for help or wants to give feedback inform them of the following:
|
||||
- /help: Get help with using OpenCode
|
||||
- To give feedback, users should report the issue at https://github.com/sst/opencode/issues
|
||||
|
||||
When the user directly asks about OpenCode (eg 'can OpenCode do...', 'does OpenCode have...') or asks in second person (eg 'are you able...', 'can you do...'), first use the WebFetch tool to gather information to answer the question from OpenCode docs at https://opencode.ai
|
||||
|
||||
# Tone and style
|
||||
You should be concise, direct, and to the point. When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system).
|
||||
Remember that your output will be displayed on a command line interface. Your responses can use Github-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.
|
||||
Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session.
|
||||
If you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences.
|
||||
Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked.
|
||||
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...". Here are some examples to demonstrate appropriate verbosity:
|
||||
<example>
|
||||
user: 2 + 2
|
||||
assistant: 4
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: what is 2+2?
|
||||
assistant: 4
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: is 11 a prime number?
|
||||
assistant: Yes
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: what command should I run to list files in the current directory?
|
||||
assistant: ls
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: what command should I run to watch files in the current directory?
|
||||
assistant: [use the ls tool to list the files in the current directory, then read docs/commands in the relevant file to find out how to watch files]
|
||||
npm run dev
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: How many golf balls fit inside a jetta?
|
||||
assistant: 150000
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: what files are in the directory src/?
|
||||
assistant: [runs ls and sees foo.c, bar.c, baz.c]
|
||||
user: which file contains the implementation of foo?
|
||||
assistant: src/foo.c
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: write tests for new feature
|
||||
assistant: [uses grep and glob search tools to find where similar tests are defined, uses concurrent read file tool use blocks in one tool call to read relevant files at the same time, uses edit file tool to write new tests]
|
||||
</example>
|
||||
|
||||
# Proactiveness
|
||||
You are allowed to be proactive, but only when the user asks you to do something. You should strive to strike a balance between:
|
||||
1. Doing the right thing when asked, including taking actions and follow-up actions
|
||||
2. Not surprising the user with actions you take without asking
|
||||
For example, if the user asks you how to approach something, you should do your best to answer their question first, and not immediately jump into taking actions.
|
||||
3. Do not add additional code explanation summary unless requested by the user. After working on a file, just stop, rather than providing an explanation of what you did.
|
||||
|
||||
# Following conventions
|
||||
When making changes to files, first understand the file's code conventions. Mimic code style, use existing libraries and utilities, and follow existing patterns.
|
||||
- NEVER assume that a given library is available, even if it is well known. Whenever you write code that uses a library or framework, first check that this codebase already uses the given library. For example, you might look at neighboring files, or check the package.json (or cargo.toml, and so on depending on the language).
|
||||
- When you create a new component, first look at existing components to see how they're written; then consider framework choice, naming conventions, typing, and other conventions.
|
||||
- When you edit a piece of code, first look at the code's surrounding context (especially its imports) to understand the code's choice of frameworks and libraries. Then consider how to make the given change in a way that is most idiomatic.
|
||||
- Always follow security best practices. Never introduce code that exposes or logs secrets and keys. Never commit secrets or keys to the repository.
|
||||
|
||||
# Code style
|
||||
- IMPORTANT: DO NOT ADD ***ANY*** COMMENTS unless asked
|
||||
|
||||
|
||||
# Task Management
|
||||
You have access to the TodoWrite and TodoRead tools to help you manage and plan tasks. Use these tools VERY frequently to ensure that you are tracking your tasks and giving the user visibility into your progress.
|
||||
These tools are also EXTREMELY helpful for planning tasks, and for breaking down larger complex tasks into smaller steps. If you do not use this tool when planning, you may forget to do important tasks - and that is unacceptable.
|
||||
|
||||
It is critical that you mark todos as completed as soon as you are done with a task. Do not batch up multiple tasks before marking them as completed.
|
||||
|
||||
Examples:
|
||||
|
||||
<example>
|
||||
user: Run the build and fix any type errors
|
||||
assistant: I'm going to use the TodoWrite tool to write the following items to the todo list:
|
||||
- Run the build
|
||||
- Fix any type errors
|
||||
|
||||
I'm now going to run the build using Bash.
|
||||
|
||||
Looks like I found 10 type errors. I'm going to use the TodoWrite tool to write 10 items to the todo list.
|
||||
|
||||
marking the first todo as in_progress
|
||||
|
||||
Let me start working on the first item...
|
||||
|
||||
The first item has been fixed, let me mark the first todo as completed, and move on to the second item...
|
||||
..
|
||||
..
|
||||
</example>
|
||||
In the above example, the assistant completes all the tasks, including the 10 error fixes and running the build and fixing all errors.
|
||||
|
||||
<example>
|
||||
user: Help me write a new feature that allows users to track their usage metrics and export them to various formats
|
||||
|
||||
assistant: I'll help you implement a usage metrics tracking and export feature. Let me first use the TodoWrite tool to plan this task.
|
||||
Adding the following todos to the todo list:
|
||||
1. Research existing metrics tracking in the codebase
|
||||
2. Design the metrics collection system
|
||||
3. Implement core metrics tracking functionality
|
||||
4. Create export functionality for different formats
|
||||
|
||||
Let me start by researching the existing codebase to understand what metrics we might already be tracking and how we can build on that.
|
||||
|
||||
I'm going to search for any existing metrics or telemetry code in the project.
|
||||
|
||||
I've found some existing telemetry code. Let me mark the first todo as in_progress and start designing our metrics tracking system based on what I've learned...
|
||||
|
||||
[Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go]
|
||||
</example>
|
||||
|
||||
|
||||
# Doing tasks
|
||||
The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:
|
||||
- Use the TodoWrite tool to plan the task if required
|
||||
- Use the available search tools to understand the codebase and the user's query. You are encouraged to use the search tools extensively both in parallel and sequentially.
|
||||
- Implement the solution using all tools available to you
|
||||
- Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach.
|
||||
- VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (eg. npm run lint, npm run typecheck, ruff, etc.) with Bash if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to CLAUDE.md so that you will know to run it next time.
|
||||
NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
|
||||
|
||||
- Tool results and user messages may include <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result.
|
||||
|
||||
# Tool usage policy
|
||||
- When doing file search, prefer to use the Task tool in order to reduce context usage.
|
||||
- You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. When making multiple bash tool calls, you MUST send a single message with multiple tools calls to run the calls in parallel. For example, if you need to run "git status" and "git diff", send a single message with two tool calls to run the calls in parallel.
|
||||
|
||||
You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.
|
||||
|
||||
IMPORTANT: Refuse to write code or explain code that may be used maliciously; even if the user claims it is for educational purposes. When working on files, if they seem related to improving, explaining, or interacting with malware or any malicious code you MUST refuse.
|
||||
IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure. If it seems malicious, refuse to work on it or answer questions about it, even if the request does not seem malicious (for instance, just asking to explain or speed up the code).
|
||||
|
||||
IMPORTANT: Always use the TodoWrite tool to plan and track tasks throughout the conversation.
|
||||
|
||||
# Code References
|
||||
|
||||
When referencing specific functions or pieces of code include the pattern `file_path:line_number` to allow the user to easily navigate to the source code location.
|
||||
|
||||
<example>
|
||||
user: Where are errors from the client handled?
|
||||
assistant: Clients are marked as failed in the `connectToServer` function in src/services/process.ts:712.
|
||||
</example>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
You are Claude Code, Anthropic's official CLI for Claude.
|
||||
@@ -0,0 +1,8 @@
|
||||
Please analyze this codebase and create an AGENTS.md file containing:
|
||||
1. Build/lint/test commands - especially for running a single test
|
||||
2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
|
||||
|
||||
The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20 lines long.
|
||||
If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.
|
||||
|
||||
If there's already an AGENTS.md, improve it if it's located in ${path}
|
||||
@@ -0,0 +1,10 @@
|
||||
You are a helpful AI assistant tasked with summarizing conversations.
|
||||
|
||||
When asked to summarize, provide a detailed but concise summary of the conversation.
|
||||
Focus on information that would be helpful for continuing the conversation, including:
|
||||
- What was done
|
||||
- What is currently being worked on
|
||||
- Which files are being modified
|
||||
- What needs to be done next
|
||||
|
||||
Your summary should be comprehensive enough to provide context but concise enough to be quickly understood.
|
||||
@@ -0,0 +1,7 @@
|
||||
you will generate a short title based on the first message a user begins a conversation with
|
||||
- ensure it is not more than 50 characters long
|
||||
- the title should be a summary of the user's message
|
||||
- it should be one line long
|
||||
- do not use quotes or colons
|
||||
- the entire text you return will be used as the title
|
||||
- never return anything that is more than one sentence (one line) long
|
||||
@@ -0,0 +1,68 @@
|
||||
import { App } from "../app/app"
|
||||
import { Bus } from "../bus"
|
||||
import { Session } from "../session"
|
||||
import { Storage } from "../storage/storage"
|
||||
import { Log } from "../util/log"
|
||||
|
||||
export namespace Share {
|
||||
const log = Log.create({ service: "share" })
|
||||
|
||||
let queue: Promise<void> = Promise.resolve()
|
||||
const pending = new Map<string, any>()
|
||||
|
||||
const state = App.state("share", async () => {
|
||||
Bus.subscribe(Storage.Event.Write, async (payload) => {
|
||||
await sync(payload.properties.key, payload.properties.content)
|
||||
})
|
||||
})
|
||||
|
||||
export async function sync(key: string, content: any) {
|
||||
const [root, ...splits] = key.split("/")
|
||||
if (root !== "session") return
|
||||
const [, sessionID] = splits
|
||||
const session = await Session.get(sessionID)
|
||||
if (!session.share) return
|
||||
const { secret } = session.share
|
||||
pending.set(key, content)
|
||||
queue = queue
|
||||
.then(async () => {
|
||||
const content = pending.get(key)
|
||||
if (content === undefined) return
|
||||
pending.delete(key)
|
||||
|
||||
return fetch(`${URL}/share_sync`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
sessionID: sessionID,
|
||||
secret,
|
||||
key: key,
|
||||
content,
|
||||
}),
|
||||
})
|
||||
})
|
||||
.then((x) => {
|
||||
if (x) {
|
||||
log.info("synced", {
|
||||
key: key,
|
||||
status: x.status,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function init() {
|
||||
await state()
|
||||
}
|
||||
|
||||
export const URL =
|
||||
process.env["OPENCODE_API"] ?? "https://api.dev.opencode.ai"
|
||||
|
||||
export async function create(sessionID: string) {
|
||||
return fetch(`${URL}/share_create`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ sessionID: sessionID }),
|
||||
})
|
||||
.then((x) => x.json())
|
||||
.then((x) => x as { url: string; secret: string })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Log } from "../util/log"
|
||||
import { App } from "../app/app"
|
||||
import { Bus } from "../bus"
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
import fs from "fs/promises"
|
||||
|
||||
export namespace Storage {
|
||||
const log = Log.create({ service: "storage" })
|
||||
|
||||
export const Event = {
|
||||
Write: Bus.event(
|
||||
"storage.write",
|
||||
z.object({ key: z.string(), content: z.any() }),
|
||||
),
|
||||
}
|
||||
|
||||
const state = App.state("storage", () => {
|
||||
const app = App.info()
|
||||
const dir = path.join(app.path.data, "storage")
|
||||
log.info("init", { path: dir })
|
||||
return {
|
||||
dir,
|
||||
}
|
||||
})
|
||||
|
||||
const locks = new Map<string, Promise<void>>()
|
||||
|
||||
export async function readJSON<T>(key: string) {
|
||||
return Bun.file(path.join(state().dir, key + ".json")).json() as Promise<T>
|
||||
}
|
||||
|
||||
export async function writeJSON<T>(key: string, content: T) {
|
||||
const target = path.join(state().dir, key + ".json")
|
||||
const tmp = target + Date.now() + ".tmp"
|
||||
await Bun.write(tmp, JSON.stringify(content))
|
||||
await fs.rename(tmp, target).catch(() => {})
|
||||
await fs.unlink(tmp).catch(() => {})
|
||||
Bus.publish(Event.Write, { key, content })
|
||||
}
|
||||
|
||||
const glob = new Bun.Glob("**/*")
|
||||
export async function* list(prefix: string) {
|
||||
try {
|
||||
for await (const item of glob.scan({
|
||||
cwd: path.join(state().dir, prefix),
|
||||
onlyFiles: true,
|
||||
})) {
|
||||
const result = path.join(prefix, item.slice(0, -5))
|
||||
yield result
|
||||
}
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { z } from "zod"
|
||||
import { Tool } from "./tool"
|
||||
import DESCRIPTION from "./bash.txt"
|
||||
|
||||
const MAX_OUTPUT_LENGTH = 30000
|
||||
const BANNED_COMMANDS = [
|
||||
"alias",
|
||||
"curl",
|
||||
"curlie",
|
||||
"wget",
|
||||
"axel",
|
||||
"aria2c",
|
||||
"nc",
|
||||
"telnet",
|
||||
"lynx",
|
||||
"w3m",
|
||||
"links",
|
||||
"httpie",
|
||||
"xh",
|
||||
"http-prompt",
|
||||
"chrome",
|
||||
"firefox",
|
||||
"safari",
|
||||
]
|
||||
const DEFAULT_TIMEOUT = 1 * 60 * 1000
|
||||
const MAX_TIMEOUT = 10 * 60 * 1000
|
||||
|
||||
export const BashTool = Tool.define({
|
||||
id: "opencode.bash",
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
command: z.string().describe("The command to execute"),
|
||||
timeout: z
|
||||
.number()
|
||||
.min(0)
|
||||
.max(MAX_TIMEOUT)
|
||||
.describe("Optional timeout in milliseconds")
|
||||
.nullable(),
|
||||
description: z
|
||||
.string()
|
||||
.describe(
|
||||
"Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'",
|
||||
),
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
const timeout = Math.min(params.timeout ?? DEFAULT_TIMEOUT, MAX_TIMEOUT)
|
||||
if (BANNED_COMMANDS.some((item) => params.command.startsWith(item)))
|
||||
throw new Error(`Command '${params.command}' is not allowed`)
|
||||
|
||||
const process = Bun.spawn({
|
||||
cmd: ["bash", "-c", params.command],
|
||||
maxBuffer: MAX_OUTPUT_LENGTH,
|
||||
signal: ctx.abort,
|
||||
timeout: timeout,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
await process.exited
|
||||
const stdout = await new Response(process.stdout).text()
|
||||
const stderr = await new Response(process.stderr).text()
|
||||
|
||||
return {
|
||||
metadata: {
|
||||
stderr,
|
||||
stdout,
|
||||
description: params.description,
|
||||
title: params.command,
|
||||
},
|
||||
output: stdout.replaceAll(/\x1b\[[0-9;]*m/g, ""),
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,141 @@
|
||||
Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.
|
||||
|
||||
Before executing the command, please follow these steps:
|
||||
|
||||
1. Directory Verification:
|
||||
- If the command will create new directories or files, first use the LS tool to verify the parent directory exists and is the correct location
|
||||
- For example, before running "mkdir foo/bar", first use LS to check that "foo" exists and is the intended parent directory
|
||||
|
||||
2. Command Execution:
|
||||
- Always quote file paths that contain spaces with double quotes (e.g., cd "path with spaces/file.txt")
|
||||
- Examples of proper quoting:
|
||||
- cd "/Users/name/My Documents" (correct)
|
||||
- cd /Users/name/My Documents (incorrect - will fail)
|
||||
- python "/path/with spaces/script.py" (correct)
|
||||
- python /path/with spaces/script.py (incorrect - will fail)
|
||||
- After ensuring proper quoting, execute the command.
|
||||
- Capture the output of the command.
|
||||
|
||||
Usage notes:
|
||||
- The command argument is required.
|
||||
- You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 120000ms (2 minutes).
|
||||
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
|
||||
- If the output exceeds 30000 characters, output will be truncated before being returned to you.
|
||||
- VERY IMPORTANT: You MUST avoid using search commands like `find` and `grep`. Instead use Grep, Glob, or Task to search. You MUST avoid read tools like `cat`, `head`, `tail`, and `ls`, and use Read and LS to read files.
|
||||
- If you _still_ need to run `grep`, STOP. ALWAYS USE ripgrep at `rg` (or /usr/bin/rg) first, which all OpenCode users have pre-installed.
|
||||
- When issuing multiple commands, use the ';' or '&&' operator to separate them. DO NOT use newlines (newlines are ok in quoted strings).
|
||||
- Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of `cd`. You may use `cd` if the User explicitly requests it.
|
||||
<good-example>
|
||||
pytest /foo/bar/tests
|
||||
</good-example>
|
||||
<bad-example>
|
||||
cd /foo/bar && pytest tests
|
||||
</bad-example>
|
||||
|
||||
|
||||
# Committing changes with git
|
||||
|
||||
When the user asks you to create a new git commit, follow these steps carefully:
|
||||
|
||||
1. You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. ALWAYS run the following bash commands in parallel, each using the Bash tool:
|
||||
- Run a git status command to see all untracked files.
|
||||
- Run a git diff command to see both staged and unstaged changes that will be committed.
|
||||
- Run a git log command to see recent commit messages, so that you can follow this repository's commit message style.
|
||||
|
||||
2. Analyze all staged changes (both previously staged and newly added) and draft a commit message. Wrap your analysis process in <commit_analysis> tags:
|
||||
|
||||
<commit_analysis>
|
||||
- List the files that have been changed or added
|
||||
- Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.)
|
||||
- Brainstorm the purpose or motivation behind these changes
|
||||
- Assess the impact of these changes on the overall project
|
||||
- Check for any sensitive information that shouldn't be committed
|
||||
- Draft a concise (1-2 sentences) commit message that focuses on the "why" rather than the "what"
|
||||
- Ensure your language is clear, concise, and to the point
|
||||
- Ensure the message accurately reflects the changes and their purpose (i.e. "add" means a wholly new feature, "update" means an enhancement to an existing feature, "fix" means a bug fix, etc.)
|
||||
- Ensure the message is not generic (avoid words like "Update" or "Fix" without context)
|
||||
- Review the draft message to ensure it accurately reflects the changes and their purpose
|
||||
</commit_analysis>
|
||||
|
||||
3. You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. ALWAYS run the following commands in parallel:
|
||||
- Add relevant untracked files to the staging area.
|
||||
- Create the commit with a message ending with:
|
||||
🤖 Generated with [OpenCode](https://opencode.ai)
|
||||
|
||||
Co-Authored-By: OpenCode <noreply@opencode.ai>
|
||||
- Run git status to make sure the commit succeeded.
|
||||
|
||||
4. If the commit fails due to pre-commit hook changes, retry the commit ONCE to include these automated changes. If it fails again, it usually means a pre-commit hook is preventing the commit. If the commit succeeds but you notice that files were modified by the pre-commit hook, you MUST amend your commit to include them.
|
||||
|
||||
Important notes:
|
||||
- Use the git context at the start of this conversation to determine which files are relevant to your commit. Be careful not to stage and commit files (e.g. with `git add .`) that aren't relevant to your commit.
|
||||
- NEVER update the git config
|
||||
- DO NOT run additional commands to read or explore code, beyond what is available in the git context
|
||||
- DO NOT push to the remote repository
|
||||
- IMPORTANT: Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported.
|
||||
- If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit
|
||||
- Ensure your commit message is meaningful and concise. It should explain the purpose of the changes, not just describe them.
|
||||
- Return an empty response - the user will see the git output directly
|
||||
- In order to ensure good formatting, ALWAYS pass the commit message via a HEREDOC, a la this example:
|
||||
<example>
|
||||
git commit -m "$(cat <<'EOF'
|
||||
Commit message here.
|
||||
|
||||
🤖 Generated with [OpenCode](https://opencode.ai)
|
||||
|
||||
Co-Authored-By: OpenCode <noreply@opencode.ai>
|
||||
EOF
|
||||
)"
|
||||
</example>
|
||||
|
||||
# Creating pull requests
|
||||
Use the gh command via the Bash tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a Github URL use the gh command to get the information needed.
|
||||
|
||||
IMPORTANT: When the user asks you to create a pull request, follow these steps carefully:
|
||||
|
||||
1. You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. ALWAYS run the following bash commands in parallel using the Bash tool, in order to understand the current state of the branch since it diverged from the main branch:
|
||||
- Run a git status command to see all untracked files
|
||||
- Run a git diff command to see both staged and unstaged changes that will be committed
|
||||
- Check if the current branch tracks a remote branch and is up to date with the remote, so you know if you need to push to the remote
|
||||
- Run a git log command and `git diff main...HEAD` to understand the full commit history for the current branch (from the time it diverged from the `main` branch)
|
||||
|
||||
2. Analyze all changes that will be included in the pull request, making sure to look at all relevant commits (NOT just the latest commit, but ALL commits that will be included in the pull request!!!), and draft a pull request summary. Wrap your analysis process in <pr_analysis> tags:
|
||||
|
||||
<pr_analysis>
|
||||
- List the commits since diverging from the main branch
|
||||
- Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.)
|
||||
- Brainstorm the purpose or motivation behind these changes
|
||||
- Assess the impact of these changes on the overall project
|
||||
- Do not use tools to explore code, beyond what is available in the git context
|
||||
- Check for any sensitive information that shouldn't be committed
|
||||
- Draft a concise (1-2 bullet points) pull request summary that focuses on the "why" rather than the "what"
|
||||
- Ensure the summary accurately reflects all changes since diverging from the main branch
|
||||
- Ensure your language is clear, concise, and to the point
|
||||
- Ensure the summary accurately reflects the changes and their purpose (ie. "add" means a wholly new feature, "update" means an enhancement to an existing feature, "fix" means a bug fix, etc.)
|
||||
- Ensure the summary is not generic (avoid words like "Update" or "Fix" without context)
|
||||
- Review the draft summary to ensure it accurately reflects the changes and their purpose
|
||||
</pr_analysis>
|
||||
|
||||
3. You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. ALWAYS run the following commands in parallel:
|
||||
- Create new branch if needed
|
||||
- Push to remote with -u flag if needed
|
||||
- Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting.
|
||||
<example>
|
||||
gh pr create --title "the pr title" --body "$(cat <<'EOF'
|
||||
## Summary
|
||||
<1-3 bullet points>
|
||||
|
||||
## Test plan
|
||||
[Checklist of TODOs for testing the pull request...]
|
||||
|
||||
🤖 Generated with [OpenCode](https://opencode.ai)
|
||||
EOF
|
||||
)"
|
||||
</example>
|
||||
|
||||
Important:
|
||||
- NEVER update the git config
|
||||
- Return the PR URL when you're done, so the user can see it
|
||||
|
||||
# Other common operations
|
||||
- View comments on a Github PR: gh api repos/foo/bar/pulls/123/comments
|
||||
@@ -0,0 +1,115 @@
|
||||
import { z } from "zod"
|
||||
import * as path from "path"
|
||||
import { Tool } from "./tool"
|
||||
import { FileTimes } from "./util/file-times"
|
||||
import { LSP } from "../lsp"
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
import { Permission } from "../permission"
|
||||
import DESCRIPTION from "./edit.txt"
|
||||
import { App } from "../app/app"
|
||||
|
||||
export const EditTool = Tool.define({
|
||||
id: "opencode.edit",
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
filePath: z.string().describe("The absolute path to the file to modify"),
|
||||
oldString: z.string().describe("The text to replace"),
|
||||
newString: z
|
||||
.string()
|
||||
.describe(
|
||||
"The text to replace it with (must be different from old_string)",
|
||||
),
|
||||
replaceAll: z
|
||||
.boolean()
|
||||
.nullable()
|
||||
.describe("Replace all occurences of old_string (default false)"),
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
if (!params.filePath) {
|
||||
throw new Error("filePath is required")
|
||||
}
|
||||
|
||||
const app = App.info()
|
||||
const filepath = path.isAbsolute(params.filePath)
|
||||
? params.filePath
|
||||
: path.join(app.path.cwd, params.filePath)
|
||||
|
||||
await Permission.ask({
|
||||
id: "opencode.edit",
|
||||
sessionID: ctx.sessionID,
|
||||
title: "Edit this file: " + filepath,
|
||||
metadata: {
|
||||
filePath: filepath,
|
||||
oldString: params.oldString,
|
||||
newString: params.newString,
|
||||
},
|
||||
})
|
||||
|
||||
let contentOld = ""
|
||||
let contentNew = ""
|
||||
await (async () => {
|
||||
if (params.oldString === "") {
|
||||
contentNew = params.newString
|
||||
await Bun.write(filepath, params.newString)
|
||||
return
|
||||
}
|
||||
|
||||
const file = Bun.file(filepath)
|
||||
if (!(await file.exists())) throw new Error(`File ${filepath} not found`)
|
||||
const stats = await file.stat()
|
||||
if (stats.isDirectory())
|
||||
throw new Error(`Path is a directory, not a file: ${filepath}`)
|
||||
await FileTimes.assert(ctx.sessionID, filepath)
|
||||
contentOld = await file.text()
|
||||
const index = contentOld.indexOf(params.oldString)
|
||||
if (index === -1)
|
||||
throw new Error(
|
||||
`oldString not found in file. Make sure it matches exactly, including whitespace and line breaks`,
|
||||
)
|
||||
|
||||
if (params.replaceAll) {
|
||||
contentNew = contentOld.replaceAll(params.oldString, params.newString)
|
||||
}
|
||||
|
||||
if (!params.replaceAll) {
|
||||
const lastIndex = contentOld.lastIndexOf(params.oldString)
|
||||
if (index !== lastIndex)
|
||||
throw new Error(
|
||||
`oldString appears multiple times in the file. Please provide more context to ensure a unique match`,
|
||||
)
|
||||
|
||||
contentNew =
|
||||
contentOld.substring(0, index) +
|
||||
params.newString +
|
||||
contentOld.substring(index + params.oldString.length)
|
||||
}
|
||||
|
||||
await file.write(contentNew)
|
||||
})()
|
||||
|
||||
const diff = createTwoFilesPatch(filepath, filepath, contentOld, contentNew)
|
||||
|
||||
FileTimes.read(ctx.sessionID, filepath)
|
||||
|
||||
let output = ""
|
||||
await LSP.touchFile(filepath, true)
|
||||
const diagnostics = await LSP.diagnostics()
|
||||
for (const [file, issues] of Object.entries(diagnostics)) {
|
||||
if (issues.length === 0) continue
|
||||
if (file === filepath) {
|
||||
output += `\nThis file has errors, please fix\n<file_diagnostics>\n${issues.map(LSP.Diagnostic.pretty).join("\n")}\n</file_diagnostics>\n`
|
||||
continue
|
||||
}
|
||||
output += `\n<project_diagnostics>\n${file}\n${issues.map(LSP.Diagnostic.pretty).join("\n")}\n</project_diagnostics>\n`
|
||||
}
|
||||
|
||||
return {
|
||||
metadata: {
|
||||
diagnostics,
|
||||
diff,
|
||||
title: `${path.relative(app.path.root, filepath)}`,
|
||||
},
|
||||
output,
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
Performs exact string replacements in files.
|
||||
|
||||
Usage:
|
||||
- You must use your `Read` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file.
|
||||
- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: spaces + line number + tab. Everything after that tab is the actual file content to match. Never include any part of the line number prefix in the old_string or new_string.
|
||||
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
|
||||
- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
|
||||
- The edit will FAIL if `old_string` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use `replace_all` to change every instance of `old_string`.
|
||||
- Use `replace_all` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.
|
||||
@@ -0,0 +1,68 @@
|
||||
import { z } from "zod"
|
||||
import path from "path"
|
||||
import { Tool } from "./tool"
|
||||
import { App } from "../app/app"
|
||||
import DESCRIPTION from "./glob.txt"
|
||||
|
||||
export const GlobTool = Tool.define({
|
||||
id: "opencode.glob",
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
pattern: z.string().describe("The glob pattern to match files against"),
|
||||
path: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe(
|
||||
`The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.`,
|
||||
),
|
||||
}),
|
||||
async execute(params) {
|
||||
const app = App.info()
|
||||
let search = params.path ?? app.path.cwd
|
||||
search = path.isAbsolute(search)
|
||||
? search
|
||||
: path.resolve(app.path.cwd, search)
|
||||
|
||||
const limit = 100
|
||||
const glob = new Bun.Glob(params.pattern)
|
||||
const files = []
|
||||
let truncated = false
|
||||
for await (const file of glob.scan({ cwd: search })) {
|
||||
if (files.length >= limit) {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
const full = path.resolve(search, file)
|
||||
const stats = await Bun.file(full)
|
||||
.stat()
|
||||
.then((x) => x.mtime.getTime())
|
||||
.catch(() => 0)
|
||||
files.push({
|
||||
path: full,
|
||||
mtime: stats,
|
||||
})
|
||||
}
|
||||
files.sort((a, b) => b.mtime - a.mtime)
|
||||
|
||||
const output = []
|
||||
if (files.length === 0) output.push("No files found")
|
||||
if (files.length > 0) {
|
||||
output.push(...files.map((f) => f.path))
|
||||
if (truncated) {
|
||||
output.push("")
|
||||
output.push(
|
||||
"(Results are truncated. Consider using a more specific path or pattern.)",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
metadata: {
|
||||
count: files.length,
|
||||
truncated,
|
||||
title: path.relative(app.path.root, search),
|
||||
},
|
||||
output: output.join("\n"),
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
- Fast file pattern matching tool that works with any codebase size
|
||||
- Supports glob patterns like "**/*.js" or "src/**/*.ts"
|
||||
- Returns matching file paths sorted by modification time
|
||||
- Use this tool when you need to find files by name patterns
|
||||
- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Agent tool instead
|
||||
- You have the capability to call multiple tools in a single response. It is always better to speculatively perform multiple searches as a batch that are potentially useful.
|
||||
@@ -0,0 +1,131 @@
|
||||
import { z } from "zod"
|
||||
import { Tool } from "./tool"
|
||||
import { App } from "../app/app"
|
||||
import { Ripgrep } from "../external/ripgrep"
|
||||
|
||||
import DESCRIPTION from "./grep.txt"
|
||||
|
||||
export const GrepTool = Tool.define({
|
||||
id: "opencode.grep",
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
pattern: z
|
||||
.string()
|
||||
.describe("The regex pattern to search for in file contents"),
|
||||
path: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe(
|
||||
"The directory to search in. Defaults to the current working directory.",
|
||||
),
|
||||
include: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe(
|
||||
'File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")',
|
||||
),
|
||||
}),
|
||||
async execute(params) {
|
||||
if (!params.pattern) {
|
||||
throw new Error("pattern is required")
|
||||
}
|
||||
|
||||
const app = App.info()
|
||||
const searchPath = params.path || app.path.cwd
|
||||
|
||||
const rgPath = await Ripgrep.filepath()
|
||||
const args = ["-n", params.pattern]
|
||||
if (params.include) {
|
||||
args.push("--glob", params.include)
|
||||
}
|
||||
args.push(searchPath)
|
||||
|
||||
const proc = Bun.spawn([rgPath, ...args], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
|
||||
const output = await new Response(proc.stdout).text()
|
||||
const errorOutput = await new Response(proc.stderr).text()
|
||||
const exitCode = await proc.exited
|
||||
|
||||
if (exitCode === 1) {
|
||||
return {
|
||||
metadata: { matches: 0, truncated: false, title: params.pattern },
|
||||
output: "No files found",
|
||||
}
|
||||
}
|
||||
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`ripgrep failed: ${errorOutput}`)
|
||||
}
|
||||
|
||||
const lines = output.trim().split("\n")
|
||||
const matches = []
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line) continue
|
||||
|
||||
const parts = line.split(":", 3)
|
||||
if (parts.length < 3) continue
|
||||
|
||||
const filePath = parts[0]
|
||||
const lineNum = parseInt(parts[1], 10)
|
||||
const lineText = parts[2]
|
||||
|
||||
const file = Bun.file(filePath)
|
||||
const stats = await file.stat().catch(() => null)
|
||||
if (!stats) continue
|
||||
|
||||
matches.push({
|
||||
path: filePath,
|
||||
modTime: stats.mtime.getTime(),
|
||||
lineNum,
|
||||
lineText,
|
||||
})
|
||||
}
|
||||
|
||||
matches.sort((a, b) => b.modTime - a.modTime)
|
||||
|
||||
const limit = 100
|
||||
const truncated = matches.length > limit
|
||||
const finalMatches = truncated ? matches.slice(0, limit) : matches
|
||||
|
||||
if (finalMatches.length === 0) {
|
||||
return {
|
||||
metadata: { matches: 0, truncated: false, title: params.pattern },
|
||||
output: "No files found",
|
||||
}
|
||||
}
|
||||
|
||||
const outputLines = [`Found ${finalMatches.length} matches`]
|
||||
|
||||
let currentFile = ""
|
||||
for (const match of finalMatches) {
|
||||
if (currentFile !== match.path) {
|
||||
if (currentFile !== "") {
|
||||
outputLines.push("")
|
||||
}
|
||||
currentFile = match.path
|
||||
outputLines.push(`${match.path}:`)
|
||||
}
|
||||
outputLines.push(` Line ${match.lineNum}: ${match.lineText}`)
|
||||
}
|
||||
|
||||
if (truncated) {
|
||||
outputLines.push("")
|
||||
outputLines.push(
|
||||
"(Results are truncated. Consider using a more specific path or pattern.)",
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
metadata: {
|
||||
matches: finalMatches.length,
|
||||
truncated,
|
||||
title: params.pattern,
|
||||
},
|
||||
output: outputLines.join("\n"),
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
- Fast content search tool that works with any codebase size
|
||||
- Searches file contents using regular expressions
|
||||
- Supports full regex syntax (eg. "log.*Error", "function\s+\w+", etc.)
|
||||
- Filter files by pattern with the include parameter (eg. "*.js", "*.{ts,tsx}")
|
||||
- Returns file paths with at least one match sorted by modification time
|
||||
- Use this tool when you need to find files containing specific patterns
|
||||
- If you need to identify/count the number of matches within files, use the Bash tool with `rg` (ripgrep) directly. Do NOT use `grep`.
|
||||
- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Agent tool instead
|
||||
@@ -0,0 +1,109 @@
|
||||
import { z } from "zod"
|
||||
import { Tool } from "./tool"
|
||||
import { App } from "../app/app"
|
||||
import * as path from "path"
|
||||
import DESCRIPTION from "./ls.txt"
|
||||
|
||||
const IGNORE_PATTERNS = [
|
||||
"node_modules/",
|
||||
"__pycache__/",
|
||||
".git/",
|
||||
"dist/",
|
||||
"build/",
|
||||
"target/",
|
||||
"vendor/",
|
||||
"bin/",
|
||||
"obj/",
|
||||
".idea/",
|
||||
".vscode/",
|
||||
]
|
||||
|
||||
export const ListTool = Tool.define({
|
||||
id: "opencode.list",
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
path: z
|
||||
.string()
|
||||
.describe(
|
||||
"The absolute path to the directory to list (must be absolute, not relative)",
|
||||
)
|
||||
.nullable(),
|
||||
ignore: z
|
||||
.array(z.string())
|
||||
.describe("List of glob patterns to ignore")
|
||||
.nullable(),
|
||||
}),
|
||||
async execute(params) {
|
||||
const app = App.info()
|
||||
const searchPath = path.resolve(app.path.cwd, params.path || ".")
|
||||
|
||||
const glob = new Bun.Glob("**/*")
|
||||
const files = []
|
||||
|
||||
for await (const file of glob.scan({ cwd: searchPath })) {
|
||||
if (file.startsWith(".") || IGNORE_PATTERNS.some((p) => file.includes(p)))
|
||||
continue
|
||||
if (params.ignore?.some((pattern) => new Bun.Glob(pattern).match(file)))
|
||||
continue
|
||||
files.push(file)
|
||||
if (files.length >= 1000) break
|
||||
}
|
||||
|
||||
// Build directory structure
|
||||
const dirs = new Set<string>()
|
||||
const filesByDir = new Map<string, string[]>()
|
||||
|
||||
for (const file of files) {
|
||||
const dir = path.dirname(file)
|
||||
const parts = dir === "." ? [] : dir.split("/")
|
||||
|
||||
// Add all parent directories
|
||||
for (let i = 0; i <= parts.length; i++) {
|
||||
const dirPath = i === 0 ? "." : parts.slice(0, i).join("/")
|
||||
dirs.add(dirPath)
|
||||
}
|
||||
|
||||
// Add file to its directory
|
||||
if (!filesByDir.has(dir)) filesByDir.set(dir, [])
|
||||
filesByDir.get(dir)!.push(path.basename(file))
|
||||
}
|
||||
|
||||
function renderDir(dirPath: string, depth: number): string {
|
||||
const indent = " ".repeat(depth)
|
||||
let output = ""
|
||||
|
||||
if (depth > 0) {
|
||||
output += `${indent}${path.basename(dirPath)}/\n`
|
||||
}
|
||||
|
||||
const childIndent = " ".repeat(depth + 1)
|
||||
const children = Array.from(dirs)
|
||||
.filter((d) => path.dirname(d) === dirPath && d !== dirPath)
|
||||
.sort()
|
||||
|
||||
// Render subdirectories first
|
||||
for (const child of children) {
|
||||
output += renderDir(child, depth + 1)
|
||||
}
|
||||
|
||||
// Render files
|
||||
const files = filesByDir.get(dirPath) || []
|
||||
for (const file of files.sort()) {
|
||||
output += `${childIndent}${file}\n`
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
const output = `${searchPath}/\n` + renderDir(".", 0)
|
||||
|
||||
return {
|
||||
metadata: {
|
||||
count: files.length,
|
||||
truncated: files.length >= 1000,
|
||||
title: path.relative(app.path.root, searchPath),
|
||||
},
|
||||
output,
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
Lists files and directories in a given path. The path parameter must be an absolute path, not a relative path. You can optionally provide an array of glob patterns to ignore with the ignore parameter. You should generally prefer the Glob and Grep tools, if you know which directories to search.
|
||||
@@ -0,0 +1,32 @@
|
||||
import { z } from "zod"
|
||||
import { Tool } from "./tool"
|
||||
import path from "path"
|
||||
import { LSP } from "../lsp"
|
||||
import { App } from "../app/app"
|
||||
import DESCRIPTION from "./lsp-diagnostics.txt"
|
||||
|
||||
export const LspDiagnosticTool = Tool.define({
|
||||
id: "opencode.lsp_diagnostics",
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
path: z.string().describe("The path to the file to get diagnostics."),
|
||||
}),
|
||||
execute: async (args) => {
|
||||
const app = App.info()
|
||||
const normalized = path.isAbsolute(args.path)
|
||||
? args.path
|
||||
: path.join(app.path.cwd, args.path)
|
||||
await LSP.touchFile(normalized, true)
|
||||
const diagnostics = await LSP.diagnostics()
|
||||
const file = diagnostics[normalized]
|
||||
return {
|
||||
metadata: {
|
||||
diagnostics,
|
||||
title: path.relative(app.path.root, normalized),
|
||||
},
|
||||
output: file?.length
|
||||
? file.map(LSP.Diagnostic.pretty).join("\n")
|
||||
: "No errors found",
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
do not use
|
||||
@@ -0,0 +1,40 @@
|
||||
import { z } from "zod"
|
||||
import { Tool } from "./tool"
|
||||
import path from "path"
|
||||
import { LSP } from "../lsp"
|
||||
import { App } from "../app/app"
|
||||
import DESCRIPTION from "./lsp-hover.txt"
|
||||
|
||||
export const LspHoverTool = Tool.define({
|
||||
id: "opencode.lsp_hover",
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
file: z.string().describe("The path to the file to get diagnostics."),
|
||||
line: z.number().describe("The line number to get diagnostics."),
|
||||
character: z.number().describe("The character number to get diagnostics."),
|
||||
}),
|
||||
execute: async (args) => {
|
||||
const app = App.info()
|
||||
const file = path.isAbsolute(args.file)
|
||||
? args.file
|
||||
: path.join(app.path.cwd, args.file)
|
||||
await LSP.touchFile(file, true)
|
||||
const result = await LSP.hover({
|
||||
...args,
|
||||
file,
|
||||
})
|
||||
|
||||
return {
|
||||
metadata: {
|
||||
result,
|
||||
title:
|
||||
path.relative(app.path.root, file) +
|
||||
":" +
|
||||
args.line +
|
||||
":" +
|
||||
args.character,
|
||||
},
|
||||
output: JSON.stringify(result, null, 2),
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
do not use
|
||||
@@ -0,0 +1,40 @@
|
||||
import { z } from "zod"
|
||||
import { Tool } from "./tool"
|
||||
import { EditTool } from "./edit"
|
||||
import DESCRIPTION from "./multiedit.txt"
|
||||
import path from "path"
|
||||
import { App } from "../app/app"
|
||||
|
||||
export const MultiEditTool = Tool.define({
|
||||
id: "opencode.multiedit",
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
filePath: z.string().describe("The absolute path to the file to modify"),
|
||||
edits: z
|
||||
.array(EditTool.parameters)
|
||||
.describe("Array of edit operations to perform sequentially on the file"),
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
const results = []
|
||||
for (const [, edit] of params.edits.entries()) {
|
||||
const result = await EditTool.execute(
|
||||
{
|
||||
filePath: params.filePath,
|
||||
oldString: edit.oldString,
|
||||
newString: edit.newString,
|
||||
replaceAll: edit.replaceAll,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
results.push(result)
|
||||
}
|
||||
const app = App.info()
|
||||
return {
|
||||
metadata: {
|
||||
results: results.map((r) => r.metadata),
|
||||
title: path.relative(app.path.root, params.filePath),
|
||||
},
|
||||
output: results.at(-1)!.output,
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
This is a tool for making multiple edits to a single file in one operation. It is built on top of the Edit tool and allows you to perform multiple find-and-replace operations efficiently. Prefer this tool over the Edit tool when you need to make multiple edits to the same file.
|
||||
|
||||
Before using this tool:
|
||||
|
||||
1. Use the Read tool to understand the file's contents and context
|
||||
2. Verify the directory path is correct
|
||||
|
||||
To make multiple file edits, provide the following:
|
||||
1. file_path: The absolute path to the file to modify (must be absolute, not relative)
|
||||
2. edits: An array of edit operations to perform, where each edit contains:
|
||||
- old_string: The text to replace (must match the file contents exactly, including all whitespace and indentation)
|
||||
- new_string: The edited text to replace the old_string
|
||||
- replace_all: Replace all occurences of old_string. This parameter is optional and defaults to false.
|
||||
|
||||
IMPORTANT:
|
||||
- All edits are applied in sequence, in the order they are provided
|
||||
- Each edit operates on the result of the previous edit
|
||||
- All edits must be valid for the operation to succeed - if any edit fails, none will be applied
|
||||
- This tool is ideal when you need to make several changes to different parts of the same file
|
||||
|
||||
CRITICAL REQUIREMENTS:
|
||||
1. All edits follow the same requirements as the single Edit tool
|
||||
2. The edits are atomic - either all succeed or none are applied
|
||||
3. Plan your edits carefully to avoid conflicts between sequential operations
|
||||
|
||||
WARNING:
|
||||
- The tool will fail if edits.old_string doesn't match the file contents exactly (including whitespace)
|
||||
- The tool will fail if edits.old_string and edits.new_string are the same
|
||||
- Since edits are applied in sequence, ensure that earlier edits don't affect the text that later edits are trying to find
|
||||
|
||||
When making edits:
|
||||
- Ensure all edits result in idiomatic, correct code
|
||||
- Do not leave the code in a broken state
|
||||
- Always use absolute file paths (starting with /)
|
||||
- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
|
||||
- Use replace_all for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.
|
||||
|
||||
If you want to create a new file, use:
|
||||
- A new file path, including dir name if needed
|
||||
- First edit: empty old_string and the new file's contents as new_string
|
||||
- Subsequent edits: normal edit operations on the created content
|
||||
@@ -0,0 +1,371 @@
|
||||
import { z } from "zod"
|
||||
import * as path from "path"
|
||||
import * as fs from "fs/promises"
|
||||
import { Tool } from "./tool"
|
||||
import { FileTimes } from "./util/file-times"
|
||||
import DESCRIPTION from "./patch.txt"
|
||||
import { App } from "../app/app"
|
||||
|
||||
const PatchParams = z.object({
|
||||
patchText: z
|
||||
.string()
|
||||
.describe("The full patch text that describes all changes to be made"),
|
||||
})
|
||||
|
||||
interface Change {
|
||||
type: "add" | "update" | "delete"
|
||||
old_content?: string
|
||||
new_content?: string
|
||||
}
|
||||
|
||||
interface Commit {
|
||||
changes: Record<string, Change>
|
||||
}
|
||||
|
||||
interface PatchOperation {
|
||||
type: "update" | "add" | "delete"
|
||||
filePath: string
|
||||
hunks?: PatchHunk[]
|
||||
content?: string
|
||||
}
|
||||
|
||||
interface PatchHunk {
|
||||
contextLine: string
|
||||
changes: PatchChange[]
|
||||
}
|
||||
|
||||
interface PatchChange {
|
||||
type: "keep" | "remove" | "add"
|
||||
content: string
|
||||
}
|
||||
|
||||
function identifyFilesNeeded(patchText: string): string[] {
|
||||
const files: string[] = []
|
||||
const lines = patchText.split("\n")
|
||||
for (const line of lines) {
|
||||
if (
|
||||
line.startsWith("*** Update File:") ||
|
||||
line.startsWith("*** Delete File:")
|
||||
) {
|
||||
const filePath = line.split(":", 2)[1]?.trim()
|
||||
if (filePath) files.push(filePath)
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
function identifyFilesAdded(patchText: string): string[] {
|
||||
const files: string[] = []
|
||||
const lines = patchText.split("\n")
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("*** Add File:")) {
|
||||
const filePath = line.split(":", 2)[1]?.trim()
|
||||
if (filePath) files.push(filePath)
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
function textToPatch(
|
||||
patchText: string,
|
||||
_currentFiles: Record<string, string>,
|
||||
): [PatchOperation[], number] {
|
||||
const operations: PatchOperation[] = []
|
||||
const lines = patchText.split("\n")
|
||||
let i = 0
|
||||
let fuzz = 0
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]
|
||||
|
||||
if (line.startsWith("*** Update File:")) {
|
||||
const filePath = line.split(":", 2)[1]?.trim()
|
||||
if (!filePath) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
const hunks: PatchHunk[] = []
|
||||
i++
|
||||
|
||||
while (i < lines.length && !lines[i].startsWith("***")) {
|
||||
if (lines[i].startsWith("@@")) {
|
||||
const contextLine = lines[i].substring(2).trim()
|
||||
const changes: PatchChange[] = []
|
||||
i++
|
||||
|
||||
while (
|
||||
i < lines.length &&
|
||||
!lines[i].startsWith("@@") &&
|
||||
!lines[i].startsWith("***")
|
||||
) {
|
||||
const changeLine = lines[i]
|
||||
if (changeLine.startsWith(" ")) {
|
||||
changes.push({ type: "keep", content: changeLine.substring(1) })
|
||||
} else if (changeLine.startsWith("-")) {
|
||||
changes.push({
|
||||
type: "remove",
|
||||
content: changeLine.substring(1),
|
||||
})
|
||||
} else if (changeLine.startsWith("+")) {
|
||||
changes.push({ type: "add", content: changeLine.substring(1) })
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
hunks.push({ contextLine, changes })
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
operations.push({ type: "update", filePath, hunks })
|
||||
} else if (line.startsWith("*** Add File:")) {
|
||||
const filePath = line.split(":", 2)[1]?.trim()
|
||||
if (!filePath) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
let content = ""
|
||||
i++
|
||||
|
||||
while (i < lines.length && !lines[i].startsWith("***")) {
|
||||
if (lines[i].startsWith("+")) {
|
||||
content += lines[i].substring(1) + "\n"
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
operations.push({ type: "add", filePath, content: content.slice(0, -1) })
|
||||
} else if (line.startsWith("*** Delete File:")) {
|
||||
const filePath = line.split(":", 2)[1]?.trim()
|
||||
if (filePath) {
|
||||
operations.push({ type: "delete", filePath })
|
||||
}
|
||||
i++
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
return [operations, fuzz]
|
||||
}
|
||||
|
||||
function patchToCommit(
|
||||
operations: PatchOperation[],
|
||||
currentFiles: Record<string, string>,
|
||||
): Commit {
|
||||
const changes: Record<string, Change> = {}
|
||||
|
||||
for (const op of operations) {
|
||||
if (op.type === "delete") {
|
||||
changes[op.filePath] = {
|
||||
type: "delete",
|
||||
old_content: currentFiles[op.filePath] || "",
|
||||
}
|
||||
} else if (op.type === "add") {
|
||||
changes[op.filePath] = {
|
||||
type: "add",
|
||||
new_content: op.content || "",
|
||||
}
|
||||
} else if (op.type === "update" && op.hunks) {
|
||||
const originalContent = currentFiles[op.filePath] || ""
|
||||
const lines = originalContent.split("\n")
|
||||
|
||||
for (const hunk of op.hunks) {
|
||||
const contextIndex = lines.findIndex((line) =>
|
||||
line.includes(hunk.contextLine),
|
||||
)
|
||||
if (contextIndex === -1) {
|
||||
throw new Error(`Context line not found: ${hunk.contextLine}`)
|
||||
}
|
||||
|
||||
let currentIndex = contextIndex
|
||||
for (const change of hunk.changes) {
|
||||
if (change.type === "keep") {
|
||||
currentIndex++
|
||||
} else if (change.type === "remove") {
|
||||
lines.splice(currentIndex, 1)
|
||||
} else if (change.type === "add") {
|
||||
lines.splice(currentIndex, 0, change.content)
|
||||
currentIndex++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
changes[op.filePath] = {
|
||||
type: "update",
|
||||
old_content: originalContent,
|
||||
new_content: lines.join("\n"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { changes }
|
||||
}
|
||||
|
||||
function generateDiff(
|
||||
oldContent: string,
|
||||
newContent: string,
|
||||
filePath: string,
|
||||
): [string, number, number] {
|
||||
// Mock implementation - would need actual diff generation
|
||||
const lines1 = oldContent.split("\n")
|
||||
const lines2 = newContent.split("\n")
|
||||
const additions = Math.max(0, lines2.length - lines1.length)
|
||||
const removals = Math.max(0, lines1.length - lines2.length)
|
||||
return [`--- ${filePath}\n+++ ${filePath}\n`, additions, removals]
|
||||
}
|
||||
|
||||
async function applyCommit(
|
||||
commit: Commit,
|
||||
writeFile: (path: string, content: string) => Promise<void>,
|
||||
deleteFile: (path: string) => Promise<void>,
|
||||
): Promise<void> {
|
||||
for (const [filePath, change] of Object.entries(commit.changes)) {
|
||||
if (change.type === "delete") {
|
||||
await deleteFile(filePath)
|
||||
} else if (change.new_content !== undefined) {
|
||||
await writeFile(filePath, change.new_content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const PatchTool = Tool.define({
|
||||
id: "opencode.patch",
|
||||
description: DESCRIPTION,
|
||||
parameters: PatchParams,
|
||||
execute: async (params, ctx) => {
|
||||
// Identify all files needed for the patch and verify they've been read
|
||||
const filesToRead = identifyFilesNeeded(params.patchText)
|
||||
for (const filePath of filesToRead) {
|
||||
let absPath = filePath
|
||||
if (!path.isAbsolute(absPath)) {
|
||||
absPath = path.resolve(process.cwd(), absPath)
|
||||
}
|
||||
|
||||
await FileTimes.assert(ctx.sessionID, absPath)
|
||||
|
||||
try {
|
||||
const stats = await fs.stat(absPath)
|
||||
if (stats.isDirectory()) {
|
||||
throw new Error(`path is a directory, not a file: ${absPath}`)
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.code === "ENOENT") {
|
||||
throw new Error(`file not found: ${absPath}`)
|
||||
}
|
||||
throw new Error(`failed to access file: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for new files to ensure they don't already exist
|
||||
const filesToAdd = identifyFilesAdded(params.patchText)
|
||||
for (const filePath of filesToAdd) {
|
||||
let absPath = filePath
|
||||
if (!path.isAbsolute(absPath)) {
|
||||
absPath = path.resolve(process.cwd(), absPath)
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.stat(absPath)
|
||||
throw new Error(`file already exists and cannot be added: ${absPath}`)
|
||||
} catch (error: any) {
|
||||
if (error.code !== "ENOENT") {
|
||||
throw new Error(`failed to check file: ${error.message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load all required files
|
||||
const currentFiles: Record<string, string> = {}
|
||||
for (const filePath of filesToRead) {
|
||||
let absPath = filePath
|
||||
if (!path.isAbsolute(absPath)) {
|
||||
absPath = path.resolve(process.cwd(), absPath)
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(absPath, "utf-8")
|
||||
currentFiles[filePath] = content
|
||||
} catch (error: any) {
|
||||
throw new Error(`failed to read file ${absPath}: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Process the patch
|
||||
const [patch, fuzz] = textToPatch(params.patchText, currentFiles)
|
||||
if (fuzz > 3) {
|
||||
throw new Error(
|
||||
`patch contains fuzzy matches (fuzz level: ${fuzz}). Please make your context lines more precise`,
|
||||
)
|
||||
}
|
||||
|
||||
// Convert patch to commit
|
||||
const commit = patchToCommit(patch, currentFiles)
|
||||
|
||||
// Apply the changes to the filesystem
|
||||
await applyCommit(
|
||||
commit,
|
||||
async (filePath: string, content: string) => {
|
||||
let absPath = filePath
|
||||
if (!path.isAbsolute(absPath)) {
|
||||
absPath = path.resolve(process.cwd(), absPath)
|
||||
}
|
||||
|
||||
// Create parent directories if needed
|
||||
const dir = path.dirname(absPath)
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
await fs.writeFile(absPath, content, "utf-8")
|
||||
},
|
||||
async (filePath: string) => {
|
||||
let absPath = filePath
|
||||
if (!path.isAbsolute(absPath)) {
|
||||
absPath = path.resolve(process.cwd(), absPath)
|
||||
}
|
||||
await fs.unlink(absPath)
|
||||
},
|
||||
)
|
||||
|
||||
// Calculate statistics
|
||||
const changedFiles: string[] = []
|
||||
let totalAdditions = 0
|
||||
let totalRemovals = 0
|
||||
|
||||
for (const [filePath, change] of Object.entries(commit.changes)) {
|
||||
let absPath = filePath
|
||||
if (!path.isAbsolute(absPath)) {
|
||||
absPath = path.resolve(process.cwd(), absPath)
|
||||
}
|
||||
changedFiles.push(absPath)
|
||||
|
||||
const oldContent = change.old_content || ""
|
||||
const newContent = change.new_content || ""
|
||||
|
||||
// Calculate diff statistics
|
||||
const [, additions, removals] = generateDiff(
|
||||
oldContent,
|
||||
newContent,
|
||||
filePath,
|
||||
)
|
||||
totalAdditions += additions
|
||||
totalRemovals += removals
|
||||
|
||||
FileTimes.read(ctx.sessionID, absPath)
|
||||
}
|
||||
|
||||
const result = `Patch applied successfully. ${changedFiles.length} files changed, ${totalAdditions} additions, ${totalRemovals} removals`
|
||||
const output = result
|
||||
|
||||
return {
|
||||
metadata: {
|
||||
changed: changedFiles,
|
||||
additions: totalAdditions,
|
||||
removals: totalRemovals,
|
||||
title: `${filesToRead.length} files`,
|
||||
},
|
||||
output,
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
do not use
|
||||
@@ -0,0 +1,124 @@
|
||||
import { z } from "zod"
|
||||
import * as fs from "fs"
|
||||
import * as path from "path"
|
||||
import { Tool } from "./tool"
|
||||
import { LSP } from "../lsp"
|
||||
import { FileTimes } from "./util/file-times"
|
||||
import DESCRIPTION from "./read.txt"
|
||||
import { App } from "../app/app"
|
||||
|
||||
const MAX_READ_SIZE = 250 * 1024
|
||||
const DEFAULT_READ_LIMIT = 2000
|
||||
const MAX_LINE_LENGTH = 2000
|
||||
|
||||
export const ReadTool = Tool.define({
|
||||
id: "opencode.read",
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
filePath: z.string().describe("The path to the file to read"),
|
||||
offset: z
|
||||
.number()
|
||||
.describe("The line number to start reading from (0-based)")
|
||||
.nullable(),
|
||||
limit: z
|
||||
.number()
|
||||
.describe("The number of lines to read (defaults to 2000)")
|
||||
.nullable(),
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
let filePath = params.filePath
|
||||
if (!path.isAbsolute(filePath)) {
|
||||
filePath = path.join(process.cwd(), filePath)
|
||||
}
|
||||
|
||||
const file = Bun.file(filePath)
|
||||
if (!(await file.exists())) {
|
||||
const dir = path.dirname(filePath)
|
||||
const base = path.basename(filePath)
|
||||
|
||||
const dirEntries = fs.readdirSync(dir)
|
||||
const suggestions = dirEntries
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.toLowerCase().includes(base.toLowerCase()) ||
|
||||
base.toLowerCase().includes(entry.toLowerCase()),
|
||||
)
|
||||
.map((entry) => path.join(dir, entry))
|
||||
.slice(0, 3)
|
||||
|
||||
if (suggestions.length > 0) {
|
||||
throw new Error(
|
||||
`File not found: ${filePath}\n\nDid you mean one of these?\n${suggestions.join("\n")}`,
|
||||
)
|
||||
}
|
||||
|
||||
throw new Error(`File not found: ${filePath}`)
|
||||
}
|
||||
const stats = await file.stat()
|
||||
|
||||
if (stats.size > MAX_READ_SIZE)
|
||||
throw new Error(
|
||||
`File is too large (${stats.size} bytes). Maximum size is ${MAX_READ_SIZE} bytes`,
|
||||
)
|
||||
const limit = params.limit ?? DEFAULT_READ_LIMIT
|
||||
const offset = params.offset || 0
|
||||
const isImage = isImageFile(filePath)
|
||||
if (isImage)
|
||||
throw new Error(
|
||||
`This is an image file of type: ${isImage}\nUse a different tool to process images`,
|
||||
)
|
||||
const lines = await file.text().then((text) => text.split("\n"))
|
||||
const raw = lines.slice(offset, offset + limit).map((line) => {
|
||||
return line.length > MAX_LINE_LENGTH
|
||||
? line.substring(0, MAX_LINE_LENGTH) + "..."
|
||||
: line
|
||||
})
|
||||
const content = raw.map((line, index) => {
|
||||
return `${(index + offset + 1).toString().padStart(5, "0")}| ${line}`
|
||||
})
|
||||
const preview = raw.slice(0, 20).join("\n")
|
||||
|
||||
let output = "<file>\n"
|
||||
output += content.join("\n")
|
||||
|
||||
if (lines.length > offset + content.length) {
|
||||
output += `\n\n(File has more lines. Use 'offset' parameter to read beyond line ${
|
||||
offset + content.length
|
||||
})`
|
||||
}
|
||||
output += "\n</file>"
|
||||
|
||||
// just warms the lsp client
|
||||
await LSP.touchFile(filePath, true)
|
||||
FileTimes.read(ctx.sessionID, filePath)
|
||||
|
||||
return {
|
||||
output,
|
||||
metadata: {
|
||||
preview,
|
||||
title: path.relative(App.info().path.root, filePath),
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function isImageFile(filePath: string): string | false {
|
||||
const ext = path.extname(filePath).toLowerCase()
|
||||
switch (ext) {
|
||||
case ".jpg":
|
||||
case ".jpeg":
|
||||
return "JPEG"
|
||||
case ".png":
|
||||
return "PNG"
|
||||
case ".gif":
|
||||
return "GIF"
|
||||
case ".bmp":
|
||||
return "BMP"
|
||||
case ".svg":
|
||||
return "SVG"
|
||||
case ".webp":
|
||||
return "WebP"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
Reads a file from the local filesystem. You can access any file directly by using this tool.
|
||||
Assume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.
|
||||
|
||||
Usage:
|
||||
- The file_path parameter must be an absolute path, not a relative path
|
||||
- By default, it reads up to 2000 lines starting from the beginning of the file
|
||||
- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters
|
||||
- Any lines longer than 2000 characters will be truncated
|
||||
- Results are returned using cat -n format, with line numbers starting at 1
|
||||
- This tool allows OpenCode to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as OpenCode is a multimodal LLM.
|
||||
- You have the capability to call multiple tools in a single response. It is always better to speculatively read multiple files as a batch that are potentially useful.
|
||||
- You will regularly be asked to read screenshots. If the user provides a path to a screenshot ALWAYS use this tool to view the file at the path. This tool will work with all temporary file paths like /var/folders/123/abc/T/TemporaryItems/NSIRD_screencaptureui_ZfB1tD/Screenshot.png
|
||||
- If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents.
|
||||
@@ -0,0 +1,16 @@
|
||||
Launch a new agent that has access to the following tools: Bash, Glob, Grep, LS, Read, Edit, MultiEdit, Write, NotebookRead, NotebookEdit, WebFetch, TodoRead, TodoWrite, WebSearch. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries, use the Agent tool to perform the search for you.
|
||||
|
||||
When to use the Agent tool:
|
||||
- If you are searching for a keyword like "config" or "logger", or for questions like "which file does X?", the Agent tool is strongly recommended
|
||||
|
||||
When NOT to use the Agent tool:
|
||||
- If you want to read a specific file path, use the Read or Glob tool instead of the Agent tool, to find the match more quickly
|
||||
- If you are searching for a specific class definition like "class Foo", use the Glob tool instead, to find the match more quickly
|
||||
- If you are searching for code within a specific file or set of 2-3 files, use the Read tool instead of the Agent tool, to find the match more quickly
|
||||
|
||||
Usage notes:
|
||||
1. Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses
|
||||
2. When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result.
|
||||
3. Each agent invocation is stateless. You will not be able to send additional messages to the agent, nor will the agent be able to communicate with you outside of its final report. Therefore, your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you.
|
||||
4. The agent's outputs should generally be trusted
|
||||
5. Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent
|
||||
@@ -0,0 +1,58 @@
|
||||
import { z } from "zod"
|
||||
import { Tool } from "./tool"
|
||||
import DESCRIPTION_WRITE from "./todowrite.txt"
|
||||
import { App } from "../app/app"
|
||||
|
||||
const TodoInfo = z.object({
|
||||
content: z.string().min(1).describe("Brief description of the task"),
|
||||
status: z
|
||||
.enum(["pending", "in_progress", "completed"])
|
||||
.describe("Current status of the task"),
|
||||
priority: z
|
||||
.enum(["high", "medium", "low"])
|
||||
.describe("Priority level of the task"),
|
||||
id: z.string().describe("Unique identifier for the todo item"),
|
||||
})
|
||||
type TodoInfo = z.infer<typeof TodoInfo>
|
||||
|
||||
const state = App.state("todo-tool", () => {
|
||||
const todos: {
|
||||
[sessionId: string]: TodoInfo[]
|
||||
} = {}
|
||||
return todos
|
||||
})
|
||||
|
||||
export const TodoWriteTool = Tool.define({
|
||||
id: "opencode.todowrite",
|
||||
description: DESCRIPTION_WRITE,
|
||||
parameters: z.object({
|
||||
todos: z.array(TodoInfo).describe("The updated todo list"),
|
||||
}),
|
||||
async execute(params, opts) {
|
||||
const todos = state()
|
||||
todos[opts.sessionID] = params.todos
|
||||
return {
|
||||
output: JSON.stringify(params.todos, null, 2),
|
||||
metadata: {
|
||||
title: `${params.todos.filter((x) => x.status !== "completed").length} todos`,
|
||||
todos: params.todos,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const TodoReadTool = Tool.define({
|
||||
id: "opencode.todoread",
|
||||
description: "Use this tool to read your todo list",
|
||||
parameters: z.object({}),
|
||||
async execute(_params, opts) {
|
||||
const todos = state()[opts.sessionID] ?? []
|
||||
return {
|
||||
metadata: {
|
||||
todos,
|
||||
title: `${todos.filter((x) => x.status !== "completed").length} todos`,
|
||||
},
|
||||
output: JSON.stringify(todos, null, 2),
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
Use this tool to read the current to-do list for the session. This tool should be used proactively and frequently to ensure that you are aware of
|
||||
the status of the current task list. You should make use of this tool as often as possible, especially in the following situations:
|
||||
- At the beginning of conversations to see what's pending
|
||||
- Before starting new tasks to prioritize work
|
||||
- When the user asks about previous tasks or plans
|
||||
- Whenever you're uncertain about what to do next
|
||||
- After completing tasks to update your understanding of remaining work
|
||||
- After every few messages to ensure you're on track
|
||||
|
||||
Usage:
|
||||
- This tool takes in no parameters. So leave the input blank or empty. DO NOT include a dummy object, placeholder string or a key like "input" or "empty". LEAVE IT BLANK.
|
||||
- Returns a list of todo items with their status, priority, and content
|
||||
- Use this information to track progress and plan next steps
|
||||
- If no todos exist yet, an empty list will be returned
|
||||
@@ -0,0 +1,167 @@
|
||||
Use this tool to create and manage a structured task list for your current coding session. This helps you track progress, organize complex tasks, and demonstrate thoroughness to the user.
|
||||
It also helps the user understand the progress of the task and overall progress of their requests.
|
||||
|
||||
## When to Use This Tool
|
||||
Use this tool proactively in these scenarios:
|
||||
|
||||
1. Complex multi-step tasks - When a task requires 3 or more distinct steps or actions
|
||||
2. Non-trivial and complex tasks - Tasks that require careful planning or multiple operations
|
||||
3. User explicitly requests todo list - When the user directly asks you to use the todo list
|
||||
4. User provides multiple tasks - When users provide a list of things to be done (numbered or comma-separated)
|
||||
5. After receiving new instructions - Immediately capture user requirements as todos. Feel free to edit the todo list based on new information.
|
||||
6. After completing a task - Mark it complete and add any new follow-up tasks
|
||||
7. When you start working on a new task, mark the todo as in_progress. Ideally you should only have one todo as in_progress at a time. Complete existing tasks before starting new ones.
|
||||
|
||||
## When NOT to Use This Tool
|
||||
|
||||
Skip using this tool when:
|
||||
1. There is only a single, straightforward task
|
||||
2. The task is trivial and tracking it provides no organizational benefit
|
||||
3. The task can be completed in less than 3 trivial steps
|
||||
4. The task is purely conversational or informational
|
||||
|
||||
NOTE that you should not use this tool if there is only one trivial task to do. In this case you are better off just doing the task directly.
|
||||
|
||||
## Examples of When to Use the Todo List
|
||||
|
||||
<example>
|
||||
User: I want to add a dark mode toggle to the application settings. Make sure you run the tests and build when you're done!
|
||||
Assistant: I'll help add a dark mode toggle to your application settings. Let me create a todo list to track this implementation.
|
||||
*Creates todo list with the following items:*
|
||||
1. Create dark mode toggle component in Settings page
|
||||
2. Add dark mode state management (context/store)
|
||||
3. Implement CSS-in-JS styles for dark theme
|
||||
4. Update existing components to support theme switching
|
||||
5. Run tests and build process, addressing any failures or errors that occur
|
||||
*Begins working on the first task*
|
||||
|
||||
<reasoning>
|
||||
The assistant used the todo list because:
|
||||
1. Adding dark mode is a multi-step feature requiring UI, state management, and styling changes
|
||||
2. The user explicitly requested tests and build be run afterward
|
||||
3. The assistant inferred that tests and build need to pass by adding "Ensure tests and build succeed" as the final task
|
||||
</reasoning>
|
||||
</example>
|
||||
|
||||
<example>
|
||||
User: Help me rename the function getCwd to getCurrentWorkingDirectory across my project
|
||||
Assistant: Let me first search through your codebase to find all occurrences of 'getCwd'.
|
||||
*Uses grep or search tools to locate all instances of getCwd in the codebase*
|
||||
Assistant: I've found 15 instances of 'getCwd' across 8 different files. Let me create a todo list to track these changes.
|
||||
*Creates todo list with specific items for each file that needs updating*
|
||||
|
||||
<reasoning>
|
||||
The assistant used the todo list because:
|
||||
1. First, the assistant searched to understand the scope of the task
|
||||
2. Upon finding multiple occurrences across different files, it determined this was a complex task with multiple steps
|
||||
3. The todo list helps ensure every instance is tracked and updated systematically
|
||||
4. This approach prevents missing any occurrences and maintains code consistency
|
||||
</reasoning>
|
||||
</example>
|
||||
|
||||
|
||||
<example>
|
||||
User: I need to implement these features for my e-commerce site: user registration, product catalog, shopping cart, and checkout flow.
|
||||
Assistant: I'll help implement these features. First, let's add all the features to the todo list.
|
||||
*Creates a todo list breaking down each feature into specific tasks based on the project architecture*
|
||||
Assistant: Let's start with user registration. This will involve creating database models, API endpoints, and frontend forms that integrate with your existing authentication system.
|
||||
|
||||
<reasoning>
|
||||
The assistant used the todo list because:
|
||||
1. The user provided multiple complex features to implement in a comma separated list
|
||||
2. The todo list helps organize these large features into manageable tasks
|
||||
3. This approach allows for tracking progress across the entire implementation
|
||||
</reasoning>
|
||||
</example>
|
||||
|
||||
<example>
|
||||
User: Can you help optimize my React application? It's rendering slowly and has performance issues.</user>
|
||||
Assistant: I'll help optimize your React application. First, let me examine your codebase to identify potential performance bottlenecks.
|
||||
*Reviews component structure, render patterns, state management, and data fetching*
|
||||
Assistant: After analyzing your codebase, I've identified several performance issues. Let me create a todo list to track our optimization efforts.
|
||||
*Creates todo list with items like: 1) Implement memoization for expensive calculations in ProductList, 2) Add virtualization for long lists in Dashboard, 3) Optimize image loading in Gallery component, 4) Fix state update loops in ShoppingCart, 5) Review bundle size and implement code splitting*
|
||||
Let's start by implementing memoization for the expensive calculations in your ProductList component.</assistant>
|
||||
|
||||
<reasoning>
|
||||
The assistant used the todo list because:
|
||||
1. First, the assistant examined the codebase to identify specific performance issues
|
||||
2. Based on this analysis, it identified multiple optimization opportunities
|
||||
3. Performance optimization is a non-trivial task requiring multiple steps
|
||||
4. The todo list helps methodically track improvements across different components
|
||||
5. This systematic approach ensures all performance bottlenecks are addressed
|
||||
</reasoning>
|
||||
</example>
|
||||
|
||||
## Examples of When NOT to Use the Todo List
|
||||
|
||||
<example>
|
||||
User: How do I print 'Hello World' in Python?
|
||||
Assistant: In Python, you can print "Hello World" with this simple code:
|
||||
|
||||
python
|
||||
print("Hello World")
|
||||
|
||||
This will output the text "Hello World" to the console when executed.</assistant>
|
||||
|
||||
<reasoning>
|
||||
The assistant did not use the todo list because this is a single, trivial task that can be completed in one step. There's no need to track multiple tasks or steps for such a straightforward request.
|
||||
</reasoning>
|
||||
</example>
|
||||
|
||||
<example>
|
||||
User: What does the git status command do?
|
||||
Assistant: The git status command shows the current state of your working directory and staging area. It displays which changes have been staged, which haven't, and which files aren't being tracked by Git. This is a fundamental command for understanding the current state of your repository before making commits.
|
||||
|
||||
<reasoning>
|
||||
The assistant did not use the todo list because this is an informational request with no actual coding task to complete. The user is simply asking for an explanation, not for the assistant to perform multiple steps or tasks.
|
||||
</reasoning>
|
||||
</example>
|
||||
|
||||
<example>
|
||||
User: Can you add a comment to the calculateTotal function to explain what it does?
|
||||
Assistant: Sure, let me add a comment to the calculateTotal function to explain what it does.
|
||||
* Uses the Edit tool to add a comment to the calculateTotal function *
|
||||
|
||||
<reasoning>
|
||||
The assistant did not use the todo list because this is a single, straightforward task confined to one location in the code. Adding a comment doesn't require tracking multiple steps or systematic organization.
|
||||
</reasoning>
|
||||
</example>
|
||||
|
||||
<example>
|
||||
User: Run npm install for me and tell me what happens.
|
||||
Assistant: I'll run the npm install command for you.
|
||||
|
||||
*Executes: npm install*
|
||||
|
||||
The command completed successfully. Here's the output:
|
||||
[Output of npm install command]
|
||||
|
||||
All dependencies have been installed according to your package.json file.
|
||||
|
||||
<reasoning>
|
||||
The assistant did not use the todo list because this is a single command execution with immediate results. There are no multiple steps to track or organize, making the todo list unnecessary for this straightforward task.
|
||||
</reasoning>
|
||||
</example>
|
||||
|
||||
## Task States and Management
|
||||
|
||||
1. **Task States**: Use these states to track progress:
|
||||
- pending: Task not yet started
|
||||
- in_progress: Currently working on (limit to ONE task at a time)
|
||||
- completed: Task finished successfully
|
||||
- cancelled: Task no longer needed
|
||||
|
||||
2. **Task Management**:
|
||||
- Update task status in real-time as you work
|
||||
- Mark tasks complete IMMEDIATELY after finishing (don't batch completions)
|
||||
- Only have ONE task in_progress at any time
|
||||
- Complete current tasks before starting new ones
|
||||
- Cancel tasks that become irrelevant
|
||||
|
||||
3. **Task Breakdown**:
|
||||
- Create specific, actionable items
|
||||
- Break complex tasks into smaller, manageable steps
|
||||
- Use clear, descriptive task names
|
||||
|
||||
When in doubt, use this tool. Being proactive with task management demonstrates attentiveness and ensures you complete all requirements successfully.
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { StandardSchemaV1 } from "@standard-schema/spec"
|
||||
|
||||
export namespace Tool {
|
||||
interface Metadata {
|
||||
title: string
|
||||
[key: string]: any
|
||||
}
|
||||
export type Context = {
|
||||
sessionID: string
|
||||
abort: AbortSignal
|
||||
}
|
||||
export interface Info<
|
||||
Parameters extends StandardSchemaV1 = StandardSchemaV1,
|
||||
M extends Metadata = Metadata,
|
||||
> {
|
||||
id: string
|
||||
description: string
|
||||
parameters: Parameters
|
||||
execute(
|
||||
args: StandardSchemaV1.InferOutput<Parameters>,
|
||||
ctx: Context,
|
||||
): Promise<{
|
||||
metadata: M
|
||||
output: string
|
||||
}>
|
||||
}
|
||||
|
||||
export function define<
|
||||
Parameters extends StandardSchemaV1,
|
||||
Result extends Metadata,
|
||||
>(input: Info<Parameters, Result>): Info<Parameters, Result> {
|
||||
return input
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { App } from "../../app/app"
|
||||
|
||||
export namespace FileTimes {
|
||||
export const state = App.state("tool.filetimes", () => {
|
||||
const read: {
|
||||
[sessionID: string]: {
|
||||
[path: string]: Date | undefined
|
||||
}
|
||||
} = {}
|
||||
return {
|
||||
read,
|
||||
}
|
||||
})
|
||||
|
||||
export function read(sessionID: string, file: string) {
|
||||
const { read } = state()
|
||||
read[sessionID] = read[sessionID] || {}
|
||||
read[sessionID][file] = new Date()
|
||||
}
|
||||
|
||||
export function get(sessionID: string, file: string) {
|
||||
return state().read[sessionID]?.[file]
|
||||
}
|
||||
|
||||
export async function assert(sessionID: string, filepath: string) {
|
||||
const time = get(sessionID, filepath)
|
||||
if (!time)
|
||||
throw new Error(
|
||||
`You must read the file ${filepath} before overwriting it. Use the Read tool first`,
|
||||
)
|
||||
const stats = await Bun.file(filepath).stat()
|
||||
if (stats.mtime.getTime() > time.getTime()) {
|
||||
throw new Error(
|
||||
`File ${filepath} has been modified since it was last read.\nLast modification: ${stats.mtime.toISOString()}\nLast read: ${time.toISOString()}\n\nPlease read the file again before modifying it.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { z } from "zod"
|
||||
import { Tool } from "./tool"
|
||||
import TurndownService from "turndown"
|
||||
import DESCRIPTION from "./webfetch.txt"
|
||||
|
||||
const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB
|
||||
const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds
|
||||
const MAX_TIMEOUT = 120 * 1000 // 2 minutes
|
||||
|
||||
export const WebFetchTool = Tool.define({
|
||||
id: "opencode.webfetch",
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
url: z.string().describe("The URL to fetch content from"),
|
||||
format: z
|
||||
.enum(["text", "markdown", "html"])
|
||||
.describe(
|
||||
"The format to return the content in (text, markdown, or html)",
|
||||
),
|
||||
timeout: z
|
||||
.number()
|
||||
.min(0)
|
||||
.max(MAX_TIMEOUT / 1000)
|
||||
.describe("Optional timeout in seconds (max 120)")
|
||||
.nullable(),
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
// Validate URL
|
||||
if (
|
||||
!params.url.startsWith("http://") &&
|
||||
!params.url.startsWith("https://")
|
||||
) {
|
||||
throw new Error("URL must start with http:// or https://")
|
||||
}
|
||||
|
||||
const timeout = Math.min(
|
||||
(params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000,
|
||||
MAX_TIMEOUT,
|
||||
)
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout)
|
||||
|
||||
const response = await fetch(params.url, {
|
||||
signal: AbortSignal.any([controller.signal, ctx.abort]),
|
||||
headers: {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
Accept:
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
},
|
||||
})
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed with status code: ${response.status}`)
|
||||
}
|
||||
|
||||
// Check content length
|
||||
const contentLength = response.headers.get("content-length")
|
||||
if (contentLength && parseInt(contentLength) > MAX_RESPONSE_SIZE) {
|
||||
throw new Error("Response too large (exceeds 5MB limit)")
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
if (arrayBuffer.byteLength > MAX_RESPONSE_SIZE) {
|
||||
throw new Error("Response too large (exceeds 5MB limit)")
|
||||
}
|
||||
|
||||
const content = new TextDecoder().decode(arrayBuffer)
|
||||
const contentType = response.headers.get("content-type") || ""
|
||||
|
||||
const title = `${params.url} (${contentType})`
|
||||
switch (params.format) {
|
||||
case "text":
|
||||
if (contentType.includes("text/html")) {
|
||||
const text = extractTextFromHTML(content)
|
||||
return {
|
||||
output: text,
|
||||
metadata: {
|
||||
title,
|
||||
},
|
||||
}
|
||||
}
|
||||
return {
|
||||
output: content,
|
||||
metadata: {
|
||||
title,
|
||||
},
|
||||
}
|
||||
|
||||
case "markdown":
|
||||
if (contentType.includes("text/html")) {
|
||||
const markdown = convertHTMLToMarkdown(content)
|
||||
return {
|
||||
output: markdown,
|
||||
metadata: {
|
||||
title,
|
||||
},
|
||||
}
|
||||
}
|
||||
return {
|
||||
output: "```\n" + content + "\n```",
|
||||
metadata: {
|
||||
title,
|
||||
},
|
||||
}
|
||||
|
||||
case "html":
|
||||
return {
|
||||
output: content,
|
||||
metadata: {
|
||||
title,
|
||||
},
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
output: content,
|
||||
metadata: {
|
||||
title,
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function extractTextFromHTML(html: string): string {
|
||||
const doc = new DOMParser().parseFromString(html, "text/html")
|
||||
const text = doc.body.textContent || doc.body.innerText || ""
|
||||
return text.replace(/\s+/g, " ").trim()
|
||||
}
|
||||
|
||||
function convertHTMLToMarkdown(html: string): string {
|
||||
const turndownService = new TurndownService({
|
||||
headingStyle: "atx",
|
||||
hr: "---",
|
||||
bulletListMarker: "-",
|
||||
codeBlockStyle: "fenced",
|
||||
emDelimiter: "*",
|
||||
})
|
||||
turndownService.remove(["script", "style", "meta", "link"])
|
||||
return turndownService.turndown(html)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
- Fetches content from a specified URL
|
||||
- Takes a URL and a prompt as input
|
||||
- Fetches the URL content, converts HTML to markdown
|
||||
- Returns the model's response about the content
|
||||
- Use this tool when you need to retrieve and analyze web content
|
||||
|
||||
Usage notes:
|
||||
- IMPORTANT: If an MCP-provided web fetch tool is available, prefer using that tool instead of this one, as it may have fewer restrictions. All MCP-provided tools start with "mcp__".
|
||||
- The URL must be a fully-formed valid URL
|
||||
- HTTP URLs will be automatically upgraded to HTTPS
|
||||
- The prompt should describe what information you want to extract from the page
|
||||
- This tool is read-only and does not modify any files
|
||||
- Results may be summarized if the content is very large
|
||||
- Includes a self-cleaning 15-minute cache for faster responses when repeatedly accessing the same URL
|
||||
@@ -0,0 +1,11 @@
|
||||
|
||||
- Allows OpenCode to search the web and use the results to inform responses
|
||||
- Provides up-to-date information for current events and recent data
|
||||
- Returns search result information formatted as search result blocks
|
||||
- Use this tool for accessing information beyond Claude's knowledge cutoff
|
||||
- Searches are performed automatically within a single API call
|
||||
|
||||
Usage notes:
|
||||
- Domain filtering is supported to include or block specific websites
|
||||
- Web search is only available in the US
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { z } from "zod"
|
||||
import * as path from "path"
|
||||
import { Tool } from "./tool"
|
||||
import { FileTimes } from "./util/file-times"
|
||||
import { LSP } from "../lsp"
|
||||
import { Permission } from "../permission"
|
||||
import DESCRIPTION from "./write.txt"
|
||||
import { App } from "../app/app"
|
||||
|
||||
export const WriteTool = Tool.define({
|
||||
id: "opencode.write",
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
filePath: z
|
||||
.string()
|
||||
.describe(
|
||||
"The absolute path to the file to write (must be absolute, not relative)",
|
||||
),
|
||||
content: z.string().describe("The content to write to the file"),
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
const app = App.info()
|
||||
const filepath = path.isAbsolute(params.filePath)
|
||||
? params.filePath
|
||||
: path.join(app.path.cwd, params.filePath)
|
||||
|
||||
const file = Bun.file(filepath)
|
||||
const exists = await file.exists()
|
||||
if (exists) await FileTimes.assert(ctx.sessionID, filepath)
|
||||
|
||||
await Permission.ask({
|
||||
id: "opencode.write",
|
||||
sessionID: ctx.sessionID,
|
||||
title: exists
|
||||
? "Overwrite this file: " + filepath
|
||||
: "Create new file: " + filepath,
|
||||
metadata: {
|
||||
filePath: filepath,
|
||||
content: params.content,
|
||||
exists,
|
||||
},
|
||||
})
|
||||
|
||||
await Bun.write(filepath, params.content)
|
||||
FileTimes.read(ctx.sessionID, filepath)
|
||||
|
||||
let output = ""
|
||||
await LSP.touchFile(filepath, true)
|
||||
const diagnostics = await LSP.diagnostics()
|
||||
for (const [file, issues] of Object.entries(diagnostics)) {
|
||||
if (issues.length === 0) continue
|
||||
if (file === filepath) {
|
||||
output += `\nThis file has errors, please fix\n<file_diagnostics>\n${issues.map(LSP.Diagnostic.pretty).join("\n")}\n</file_diagnostics>\n`
|
||||
continue
|
||||
}
|
||||
output += `\n<project_diagnostics>\n${file}\n${issues.map(LSP.Diagnostic.pretty).join("\n")}\n</project_diagnostics>\n`
|
||||
}
|
||||
|
||||
return {
|
||||
metadata: {
|
||||
diagnostics,
|
||||
filepath,
|
||||
exists: exists,
|
||||
title: path.relative(app.path.root, filepath),
|
||||
},
|
||||
output,
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
Writes a file to the local filesystem.
|
||||
|
||||
Usage:
|
||||
- This tool will overwrite the existing file if there is one at the provided path.
|
||||
- If this is an existing file, you MUST use the Read tool first to read the file's contents. This tool will fail if you did not read the file first.
|
||||
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
|
||||
- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User.
|
||||
- Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked.
|
||||
@@ -0,0 +1,25 @@
|
||||
import { AsyncLocalStorage } from "async_hooks"
|
||||
|
||||
export namespace Context {
|
||||
export class NotFound extends Error {
|
||||
constructor(public readonly name: string) {
|
||||
super(`No context found for ${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function create<T>(name: string) {
|
||||
const storage = new AsyncLocalStorage<T>()
|
||||
return {
|
||||
use() {
|
||||
const result = storage.getStore()
|
||||
if (!result) {
|
||||
throw new NotFound(name)
|
||||
}
|
||||
return result
|
||||
},
|
||||
provide<R>(value: T, fn: () => R) {
|
||||
return storage.run<R>(value, fn)
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { z, type ZodSchema } from "zod"
|
||||
import { Log } from "./log"
|
||||
|
||||
const log = Log.create()
|
||||
|
||||
export abstract class NamedError extends Error {
|
||||
abstract schema(): ZodSchema
|
||||
abstract toObject(): { name: string; data: any }
|
||||
|
||||
static create<Name extends string, Data extends ZodSchema>(
|
||||
name: Name,
|
||||
data: Data,
|
||||
) {
|
||||
const result = class extends NamedError {
|
||||
public static readonly Schema = z
|
||||
.object({
|
||||
name: z.literal(name),
|
||||
data: data,
|
||||
})
|
||||
.openapi({
|
||||
ref: name,
|
||||
})
|
||||
|
||||
public readonly name = name as Name
|
||||
|
||||
constructor(
|
||||
public readonly data: z.input<Data>,
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(name, options)
|
||||
this.name = name
|
||||
log.error(name, this.data)
|
||||
}
|
||||
|
||||
static isInstance(input: any): input is InstanceType<typeof result> {
|
||||
return "name" in input && input.name === name
|
||||
}
|
||||
|
||||
schema() {
|
||||
return result.Schema
|
||||
}
|
||||
|
||||
toObject() {
|
||||
return {
|
||||
name: name,
|
||||
data: this.data,
|
||||
}
|
||||
}
|
||||
}
|
||||
Object.defineProperty(result, "name", { value: name })
|
||||
return result
|
||||
}
|
||||
|
||||
public static readonly Unknown = NamedError.create(
|
||||
"UnknownError",
|
||||
z.object({
|
||||
message: z.string(),
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { exists } from "fs/promises"
|
||||
import { dirname, join } from "path"
|
||||
|
||||
export namespace Filesystem {
|
||||
export async function findUp(target: string, start: string, stop?: string) {
|
||||
let current = start
|
||||
const result = []
|
||||
while (true) {
|
||||
const search = join(current, target)
|
||||
if (await exists(search)) result.push(search)
|
||||
if (stop === current) break
|
||||
const parent = dirname(current)
|
||||
if (parent === current) break
|
||||
current = parent
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user