Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfc715bd48 | ||
|
|
39bcba85a9 | ||
|
|
da3df51316 | ||
|
|
12190e4efc | ||
|
|
d2a9b2f64a | ||
|
|
aacadd8a8a | ||
|
|
969154a473 | ||
|
|
4d6ca3fab1 | ||
|
|
00ea5082e7 | ||
|
|
4a878b88c0 | ||
|
|
6de955847c | ||
|
|
3ba5d528b4 | ||
|
|
f99e2b3429 | ||
|
|
7e4e6f6e51 | ||
|
|
0514f3f43b | ||
|
|
1e07384364 | ||
|
|
4c4739c422 | ||
|
|
2d8b90a6ff | ||
|
|
a2fa7ffa42 | ||
|
|
f7d6175283 | ||
|
|
9ed187ee52 | ||
|
|
14d81e574b | ||
|
|
6efe8cc8df | ||
|
|
daa5fc916a | ||
|
|
c659496b96 | ||
|
|
21fbf21cb6 | ||
|
|
f31cbf2744 | ||
|
|
8322f18e03 | ||
|
|
562bdb95e2 | ||
|
|
a57ce8365d | ||
|
|
0da83ae67e | ||
|
|
662d022a48 | ||
|
|
9efef03919 | ||
|
|
7a9fb3fa92 | ||
|
|
ea96ead346 | ||
|
|
6100a77b85 | ||
|
|
c7a59ee2b1 | ||
|
|
a272b58fe9 | ||
|
|
9948fcf1b6 | ||
|
|
0d50c867ff | ||
|
|
27f7e02f12 | ||
|
|
0f93ecd564 | ||
|
|
da909d9684 | ||
|
|
facd851b11 | ||
|
|
c51de945a5 | ||
|
|
9253a3ca9e | ||
|
|
7cfa297a78 | ||
|
|
661b74def6 | ||
|
|
b478e5655c | ||
|
|
f884766445 |
@@ -28,5 +28,5 @@ jobs:
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "GitHub Action"
|
||||
git add STATS.md
|
||||
git diff --staged --quiet || git commit -m "Update download stats $(date -I)"
|
||||
git diff --staged --quiet || git commit -m "ignore: update download stats $(date -I)"
|
||||
git push
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
# Download Stats
|
||||
|
||||
| Date | GitHub Downloads | npm Downloads | Total |
|
||||
| ---------- | ---------------- | --------------- | --------------- |
|
||||
| 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) |
|
||||
| 2025-06-30 | 20,127 (+1,338) | 41,059 (+1,639) | 61,186 (+2,977) |
|
||||
| 2025-07-01 | 22,108 (+1,981) | 43,745 (+2,686) | 65,853 (+4,667) |
|
||||
| 2025-07-02 | 24,814 (+2,706) | 46,168 (+2,423) | 70,982 (+5,129) |
|
||||
| 2025-07-03 | 27,834 (+3,020) | 49,955 (+3,787) | 77,789 (+6,807) |
|
||||
| 2025-07-04 | 30,608 (+2,774) | 54,758 (+4,803) | 85,366 (+7,577) |
|
||||
| 2025-07-05 | 32,524 (+1,916) | 58,371 (+3,613) | 90,895 (+5,529) |
|
||||
| 2025-07-06 | 33,766 (+1,242) | 59,694 (+1,323) | 93,460 (+2,565) |
|
||||
| Date | GitHub Downloads | npm Downloads | Total |
|
||||
| ---------- | ---------------- | --------------- | ---------------- |
|
||||
| 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) |
|
||||
| 2025-06-30 | 20,127 (+1,338) | 41,059 (+1,639) | 61,186 (+2,977) |
|
||||
| 2025-07-01 | 22,108 (+1,981) | 43,745 (+2,686) | 65,853 (+4,667) |
|
||||
| 2025-07-02 | 24,814 (+2,706) | 46,168 (+2,423) | 70,982 (+5,129) |
|
||||
| 2025-07-03 | 27,834 (+3,020) | 49,955 (+3,787) | 77,789 (+6,807) |
|
||||
| 2025-07-04 | 30,608 (+2,774) | 54,758 (+4,803) | 85,366 (+7,577) |
|
||||
| 2025-07-05 | 32,524 (+1,916) | 58,371 (+3,613) | 90,895 (+5,529) |
|
||||
| 2025-07-06 | 33,766 (+1,242) | 59,694 (+1,323) | 93,460 (+2,565) |
|
||||
| 2025-07-08 | 38,052 (+4,286) | 64,468 (+4,774) | 102,520 (+9,060) |
|
||||
|
||||
@@ -96,30 +96,22 @@
|
||||
"sharp",
|
||||
"esbuild",
|
||||
],
|
||||
"patchedDependencies": {
|
||||
"ai@4.3.16": "patches/ai@4.3.16.patch",
|
||||
},
|
||||
"overrides": {
|
||||
"zod": "3.24.2",
|
||||
},
|
||||
"catalog": {
|
||||
"@types/node": "22.13.9",
|
||||
"ai": "4.3.16",
|
||||
"ai": "5.0.0-beta.7",
|
||||
"typescript": "5.8.2",
|
||||
"zod": "3.24.2",
|
||||
"zod": "3.25.49",
|
||||
},
|
||||
"packages": {
|
||||
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@2.2.10", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-icLGO7Q0NinnHIPgT+y1QjHVwH4HwV+brWbvM+FfCG2Afpa89PyKa3Ret91kGjZpBgM/xnj1B7K5eM+rRlsXQA=="],
|
||||
|
||||
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@1.2.12", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-YSzjlko7JvuiyQFmI9RN1tNZdEiZxc+6xld/0tq/VkJaHpEzGAb1yiNxxvmYVcjvfu/PcvCxAAYXmTYQQ63IHQ=="],
|
||||
|
||||
"@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="],
|
||||
"@ai-sdk/gateway": ["@ai-sdk/gateway@1.0.0-beta.3", "", { "dependencies": { "@ai-sdk/provider": "2.0.0-beta.1", "@ai-sdk/provider-utils": "3.0.0-beta.2" }, "peerDependencies": { "zod": "^3.25.49" } }, "sha512-g49gMSkXy94lYvl5LRh438OR/0JCG6ol0jV+iLot7cy5HLltZlGocEuauETBu4b10mDXOd7XIjTEZoQpYFMYLQ=="],
|
||||
|
||||
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="],
|
||||
"@ai-sdk/provider": ["@ai-sdk/provider@2.0.0-beta.1", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Z8SPncMtS3RsoXITmT7NVwrAq6M44dmw0DoUOYJqNNtCu8iMWuxB8Nxsoqpa0uEEy9R1V1ZThJAXTYgjTUxl3w=="],
|
||||
|
||||
"@ai-sdk/react": ["@ai-sdk/react@1.2.12", "", { "dependencies": { "@ai-sdk/provider-utils": "2.2.8", "@ai-sdk/ui-utils": "1.2.11", "swr": "^2.2.5", "throttleit": "2.1.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "zod": "^3.23.8" }, "optionalPeers": ["zod"] }, "sha512-jK1IZZ22evPZoQW3vlkZ7wvjYGYF+tRBKXtrcolduIkQ/m/sOAVcVeVDUDvh1T91xCnWCdUGCPZg2avZ90mv3g=="],
|
||||
|
||||
"@ai-sdk/ui-utils": ["@ai-sdk/ui-utils@1.2.11", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w=="],
|
||||
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.0-beta.2", "", { "dependencies": { "@ai-sdk/provider": "2.0.0-beta.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.49" } }, "sha512-H4K+4weOVgWqrDDeAbQWoA4U5mN4WrQPHQFdH7ynQYcnhj/pzctU9Q6mGlR5ESMWxaXxazxlOblSITlXo9bahA=="],
|
||||
|
||||
"@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
|
||||
|
||||
@@ -467,8 +459,6 @@
|
||||
|
||||
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
|
||||
|
||||
"@types/diff-match-patch": ["@types/diff-match-patch@1.0.36", "", {}, "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="],
|
||||
|
||||
"@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
|
||||
@@ -515,7 +505,7 @@
|
||||
|
||||
"acorn-walk": ["acorn-walk@8.3.2", "", {}, "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A=="],
|
||||
|
||||
"ai": ["ai@4.3.16", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "@ai-sdk/react": "1.2.12", "@ai-sdk/ui-utils": "1.2.11", "@opentelemetry/api": "1.9.0", "jsondiffpatch": "0.6.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "zod": "^3.23.8" }, "optionalPeers": ["react"] }, "sha512-KUDwlThJ5tr2Vw0A1ZkbDKNME3wzWhuVfAOwIvFUzl1TPVDFAXDFTXio3p+jaKneB+dKNCvFFlolYmmgHttG1g=="],
|
||||
"ai": ["ai@5.0.0-beta.7", "", { "dependencies": { "@ai-sdk/gateway": "1.0.0-beta.3", "@ai-sdk/provider": "2.0.0-beta.1", "@ai-sdk/provider-utils": "3.0.0-beta.2", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.49" } }, "sha512-oC4KzUJCQPMB7v9rCqL/rVk2ogZvI6lYiXfKjzPYHwa1zIgy329qqRLmAd3mKEDTTG6By1r0zasQu7FKmG+4gw=="],
|
||||
|
||||
"ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="],
|
||||
|
||||
@@ -733,8 +723,6 @@
|
||||
|
||||
"diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="],
|
||||
|
||||
"diff-match-patch": ["diff-match-patch@1.0.5", "", {}, "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw=="],
|
||||
|
||||
"diff3": ["diff3@0.0.3", "", {}, "sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g=="],
|
||||
|
||||
"direction": ["direction@2.0.1", "", { "bin": { "direction": "cli.js" } }, "sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA=="],
|
||||
@@ -803,7 +791,7 @@
|
||||
|
||||
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
|
||||
|
||||
"eventsource-parser": ["eventsource-parser@3.0.2", "", {}, "sha512-6RxOBZ/cYgd8usLwsEl+EC09Au/9BcmCKYF2/xbml6DNczf7nv0MQb+7BA2F+li6//I+28VNlQR37XfQtcAJuA=="],
|
||||
"eventsource-parser": ["eventsource-parser@3.0.3", "", {}, "sha512-nVpZkTMM9rF6AQ9gPJpFsNAMt48wIzB5TQgiTLdHiuO8XEDhUgZEhqKlZWXbIzo9VmJ/HvysHqEaVeD5v9TPvA=="],
|
||||
|
||||
"exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="],
|
||||
|
||||
@@ -1025,8 +1013,6 @@
|
||||
|
||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"jsondiffpatch": ["jsondiffpatch@0.6.0", "", { "dependencies": { "@types/diff-match-patch": "^1.0.36", "chalk": "^5.3.0", "diff-match-patch": "^1.0.5" }, "bin": { "jsondiffpatch": "bin/jsondiffpatch.js" } }, "sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ=="],
|
||||
|
||||
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||
|
||||
"klona": ["klona@2.0.6", "", {}, "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA=="],
|
||||
@@ -1343,8 +1329,6 @@
|
||||
|
||||
"rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
|
||||
|
||||
"react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="],
|
||||
|
||||
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
|
||||
|
||||
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||
@@ -1529,8 +1513,6 @@
|
||||
|
||||
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"swr": ["swr@2.3.3", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-dshNvs3ExOqtZ6kJBaAsabhPdHyeY4P2cKwRCniDVifBMoG/SVI7tfLWqPXriVspf2Rg4tPzXJTnwaihIeFw2A=="],
|
||||
|
||||
"tar-fs": ["tar-fs@3.0.9", "", { "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" }, "optionalDependencies": { "bare-fs": "^4.0.1", "bare-path": "^3.0.0" } }, "sha512-XF4w9Xp+ZQgifKakjZYmFdkLoSWd34VGKcsTCwlNWM7QG3ZbaxnTsaBwnjFZqHRf/rROxaR8rXnbtwdvaDI+lA=="],
|
||||
|
||||
"tar-stream": ["tar-stream@3.1.7", "", { "dependencies": { "b4a": "^1.6.4", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ=="],
|
||||
@@ -1539,8 +1521,6 @@
|
||||
|
||||
"thread-stream": ["thread-stream@0.15.2", "", { "dependencies": { "real-require": "^0.1.0" } }, "sha512-UkEhKIg2pD+fjkHQKyJO3yoIvAP3N6RlNFt2dUhcS1FGvCD1cQa1M/PGknCLFIyZdtJOWQjejp7bdNqmN7zwdA=="],
|
||||
|
||||
"throttleit": ["throttleit@2.1.0", "", {}, "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw=="],
|
||||
|
||||
"tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="],
|
||||
|
||||
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
|
||||
@@ -1625,8 +1605,6 @@
|
||||
|
||||
"url": ["url@0.10.3", "", { "dependencies": { "punycode": "1.3.2", "querystring": "0.2.0" } }, "sha512-hzSUW2q06EqL1gKM/a+obYHLIO6ct2hwPuviqTTOcfFVc61UbfJ2Q32+uGL/HCPxKqrdGB5QUwIe7UqlDgwsOQ=="],
|
||||
|
||||
"use-sync-external-store": ["use-sync-external-store@1.5.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A=="],
|
||||
|
||||
"util": ["util@0.12.5", "", { "dependencies": { "inherits": "^2.0.3", "is-arguments": "^1.0.4", "is-generator-function": "^1.0.7", "is-typed-array": "^1.1.3", "which-typed-array": "^1.1.2" } }, "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA=="],
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
@@ -1703,7 +1681,7 @@
|
||||
|
||||
"youch": ["youch@3.3.4", "", { "dependencies": { "cookie": "^0.7.1", "mustache": "^4.2.0", "stacktracey": "^2.1.8" } }, "sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg=="],
|
||||
|
||||
"zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="],
|
||||
"zod": ["zod@3.25.49", "", {}, "sha512-JMMPMy9ZBk3XFEdbM3iL1brx4NUSejd6xr3ELrrGEfGb355gjhiAWtG3K5o+AViV/3ZfkIrCzXsZn6SbLwTR8Q=="],
|
||||
|
||||
"zod-openapi": ["zod-openapi@4.2.4", "", { "peerDependencies": { "zod": "^3.21.4" } }, "sha512-tsrQpbpqFCXqVXUzi3TPwFhuMtLN3oNZobOtYnK6/5VkXsNdnIgyNr4r8no4wmYluaxzN3F7iS+8xCW8BmMQ8g=="],
|
||||
|
||||
@@ -1715,12 +1693,22 @@
|
||||
|
||||
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock/@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock/aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="],
|
||||
|
||||
"@ai-sdk/anthropic/@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="],
|
||||
|
||||
"@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="],
|
||||
|
||||
"@ampproject/remapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
|
||||
|
||||
"@astrojs/mdx/@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.2", "", { "dependencies": { "@astrojs/internal-helpers": "0.6.1", "@astrojs/prism": "3.3.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.1.0", "js-yaml": "^4.1.0", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^3.2.1", "smol-toml": "^1.3.1", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.1", "vfile": "^6.0.3" } }, "sha512-bO35JbWpVvyKRl7cmSJD822e8YA8ThR/YbUsciWNA7yTcqpIAL2hJDToWP5KcZBWxGT6IOdOkHSXARSNZc4l/Q=="],
|
||||
|
||||
"@astrojs/sitemap/zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="],
|
||||
|
||||
"@aws-crypto/crc32/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
|
||||
@@ -1739,6 +1727,8 @@
|
||||
|
||||
"@jridgewell/gen-mapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="],
|
||||
|
||||
"@openauthjs/openauth/@standard-schema/spec": ["@standard-schema/spec@1.0.0-beta.3", "", {}, "sha512-0ifF3BjA1E8SY9C+nUew8RefNOIq0cDlYALPty4rhUm8Rrl6tCM8hBT4bhGhx7I7iXD0uAgt50lgo8dD73ACMw=="],
|
||||
|
||||
"@openauthjs/openauth/aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="],
|
||||
@@ -1775,10 +1765,14 @@
|
||||
|
||||
"astro/sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="],
|
||||
|
||||
"astro/zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="],
|
||||
|
||||
"babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="],
|
||||
|
||||
"bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
|
||||
|
||||
"eventsource/eventsource-parser": ["eventsource-parser@3.0.2", "", {}, "sha512-6RxOBZ/cYgd8usLwsEl+EC09Au/9BcmCKYF2/xbml6DNczf7nv0MQb+7BA2F+li6//I+28VNlQR37XfQtcAJuA=="],
|
||||
|
||||
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
"get-source/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
@@ -1791,8 +1785,12 @@
|
||||
|
||||
"miniflare/sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="],
|
||||
|
||||
"miniflare/zod": ["zod@3.22.3", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="],
|
||||
|
||||
"opencontrol/hono": ["hono@4.7.4", "", {}, "sha512-Pst8FuGqz3L7tFF+u9Pu70eI0xa5S3LPUmrNd5Jm8nTHze9FxLTK9Kaj5g/k4UcwuJSXTP65SyHOPLrffpcAJg=="],
|
||||
|
||||
"opencontrol/zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="],
|
||||
|
||||
"opencontrol/zod-to-json-schema": ["zod-to-json-schema@3.24.3", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-HIAfWdYIt1sssHfYZFCXp4rU1w2r8hVVXYIlmoa0r0gABLs5di3RCqPU5DDROogVz1pAdYBaz7HK5n9pSUNs3A=="],
|
||||
|
||||
"openid-client/jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="],
|
||||
|
||||
@@ -39,6 +39,8 @@ new sst.cloudflare.x.Astro("Web", {
|
||||
domain,
|
||||
path: "packages/web",
|
||||
environment: {
|
||||
// For astro config
|
||||
SST_STAGE: $app.stage,
|
||||
VITE_API_URL: api.url,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"mcp": {
|
||||
"weather": {
|
||||
"type": "local",
|
||||
"command": ["opencode", "x", "@h1deya/mcp-server-weather"]
|
||||
}
|
||||
},
|
||||
"experimental": {
|
||||
"hook": {
|
||||
"file_edited": {
|
||||
|
||||
+5
-7
@@ -7,7 +7,7 @@
|
||||
"scripts": {
|
||||
"dev": "bun run packages/opencode/src/index.ts",
|
||||
"typecheck": "bun run --filter='*' typecheck",
|
||||
"stainless": "bun run ./packages/opencode/src/index.ts serve ",
|
||||
"stainless": "./scripts/stainless",
|
||||
"postinstall": "./scripts/hooks"
|
||||
},
|
||||
"workspaces": {
|
||||
@@ -17,8 +17,8 @@
|
||||
"catalog": {
|
||||
"typescript": "5.8.2",
|
||||
"@types/node": "22.13.9",
|
||||
"zod": "3.24.2",
|
||||
"ai": "4.3.16"
|
||||
"zod": "3.25.49",
|
||||
"ai": "5.0.0-beta.7"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -31,10 +31,8 @@
|
||||
},
|
||||
"license": "MIT",
|
||||
"prettier": {
|
||||
"semi": false
|
||||
},
|
||||
"overrides": {
|
||||
"zod": "3.24.2"
|
||||
"semi": false,
|
||||
"printWidth": 120
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"esbuild",
|
||||
|
||||
@@ -38,10 +38,7 @@ export class SyncServer extends DurableObject<Env> {
|
||||
|
||||
async publish(key: string, content: any) {
|
||||
const sessionID = await this.getSessionID()
|
||||
if (
|
||||
!key.startsWith(`session/info/${sessionID}`) &&
|
||||
!key.startsWith(`session/message/${sessionID}/`)
|
||||
)
|
||||
if (!key.startsWith(`session/info/${sessionID}`) && !key.startsWith(`session/message/${sessionID}/`))
|
||||
return new Response("Error: Invalid key", { status: 400 })
|
||||
|
||||
// store message
|
||||
@@ -184,8 +181,7 @@ export default {
|
||||
}
|
||||
const id = url.searchParams.get("id")
|
||||
console.log("share_poll", id)
|
||||
if (!id)
|
||||
return new Response("Error: Share ID is required", { status: 400 })
|
||||
if (!id) return new Response("Error: Share ID is required", { status: 400 })
|
||||
const stub = env.SYNC_SERVER.get(env.SYNC_SERVER.idFromName(id))
|
||||
return stub.fetch(request)
|
||||
}
|
||||
@@ -193,8 +189,7 @@ export default {
|
||||
if (request.method === "GET" && method === "share_data") {
|
||||
const id = url.searchParams.get("id")
|
||||
console.log("share_data", id)
|
||||
if (!id)
|
||||
return new Response("Error: Share ID is required", { status: 400 })
|
||||
if (!id) return new Response("Error: Share ID is required", { status: 400 })
|
||||
const stub = env.SYNC_SERVER.get(env.SYNC_SERVER.idFromName(id))
|
||||
const data = await stub.getData()
|
||||
|
||||
|
||||
@@ -14,111 +14,267 @@
|
||||
"properties": {
|
||||
"leader": {
|
||||
"type": "string",
|
||||
"description": "Leader key for keybind combinations"
|
||||
"default": "ctrl+x",
|
||||
"description": "Leader key for keybind combinations\n\ndefault: `ctrl+x`",
|
||||
"examples": [
|
||||
"ctrl+x"
|
||||
]
|
||||
},
|
||||
"help": {
|
||||
"app_help": {
|
||||
"type": "string",
|
||||
"description": "Show help dialog"
|
||||
"default": "<leader>h",
|
||||
"description": "Show help dialog\n\ndefault: `<leader>h`",
|
||||
"examples": [
|
||||
"<leader>h"
|
||||
]
|
||||
},
|
||||
"editor_open": {
|
||||
"type": "string",
|
||||
"description": "Open external editor"
|
||||
"default": "<leader>e",
|
||||
"description": "Open external editor\n\ndefault: `<leader>e`",
|
||||
"examples": [
|
||||
"<leader>e"
|
||||
]
|
||||
},
|
||||
"session_new": {
|
||||
"type": "string",
|
||||
"description": "Create a new session"
|
||||
"default": "<leader>n",
|
||||
"description": "Create a new session\n\ndefault: `<leader>n`",
|
||||
"examples": [
|
||||
"<leader>n"
|
||||
]
|
||||
},
|
||||
"session_list": {
|
||||
"type": "string",
|
||||
"description": "List all sessions"
|
||||
"default": "<leader>l",
|
||||
"description": "List all sessions\n\ndefault: `<leader>l`",
|
||||
"examples": [
|
||||
"<leader>l"
|
||||
]
|
||||
},
|
||||
"session_share": {
|
||||
"type": "string",
|
||||
"description": "Share current session"
|
||||
"default": "<leader>s",
|
||||
"description": "Share current session\n\ndefault: `<leader>s`",
|
||||
"examples": [
|
||||
"<leader>s"
|
||||
]
|
||||
},
|
||||
"session_unshare": {
|
||||
"type": "string",
|
||||
"default": "<leader>u",
|
||||
"description": "Unshare current session\n\ndefault: `<leader>u`",
|
||||
"examples": [
|
||||
"<leader>u"
|
||||
]
|
||||
},
|
||||
"session_interrupt": {
|
||||
"type": "string",
|
||||
"description": "Interrupt current session"
|
||||
"default": "esc",
|
||||
"description": "Interrupt current session\n\ndefault: `esc`",
|
||||
"examples": [
|
||||
"esc"
|
||||
]
|
||||
},
|
||||
"session_compact": {
|
||||
"type": "string",
|
||||
"description": "Toggle compact mode for session"
|
||||
"default": "<leader>c",
|
||||
"description": "Compact the session\n\ndefault: `<leader>c`",
|
||||
"examples": [
|
||||
"<leader>c"
|
||||
]
|
||||
},
|
||||
"tool_details": {
|
||||
"type": "string",
|
||||
"description": "Show tool details"
|
||||
"default": "<leader>d",
|
||||
"description": "Toggle tool details\n\ndefault: `<leader>d`",
|
||||
"examples": [
|
||||
"<leader>d"
|
||||
]
|
||||
},
|
||||
"model_list": {
|
||||
"type": "string",
|
||||
"description": "List available models"
|
||||
"default": "<leader>m",
|
||||
"description": "List available models\n\ndefault: `<leader>m`",
|
||||
"examples": [
|
||||
"<leader>m"
|
||||
]
|
||||
},
|
||||
"theme_list": {
|
||||
"type": "string",
|
||||
"description": "List available themes"
|
||||
"default": "<leader>t",
|
||||
"description": "List available themes\n\ndefault: `<leader>t`",
|
||||
"examples": [
|
||||
"<leader>t"
|
||||
]
|
||||
},
|
||||
"file_list": {
|
||||
"type": "string",
|
||||
"default": "<leader>f",
|
||||
"description": "List files\n\ndefault: `<leader>f`",
|
||||
"examples": [
|
||||
"<leader>f"
|
||||
]
|
||||
},
|
||||
"file_close": {
|
||||
"type": "string",
|
||||
"default": "esc",
|
||||
"description": "Close file\n\ndefault: `esc`",
|
||||
"examples": [
|
||||
"esc"
|
||||
]
|
||||
},
|
||||
"file_search": {
|
||||
"type": "string",
|
||||
"default": "<leader>/",
|
||||
"description": "Search file\n\ndefault: `<leader>/`",
|
||||
"examples": [
|
||||
"<leader>/"
|
||||
]
|
||||
},
|
||||
"file_diff_toggle": {
|
||||
"type": "string",
|
||||
"default": "<leader>v",
|
||||
"description": "Split/unified diff\n\ndefault: `<leader>v`",
|
||||
"examples": [
|
||||
"<leader>v"
|
||||
]
|
||||
},
|
||||
"project_init": {
|
||||
"type": "string",
|
||||
"description": "Initialize project configuration"
|
||||
"default": "<leader>i",
|
||||
"description": "Create/update AGENTS.md\n\ndefault: `<leader>i`",
|
||||
"examples": [
|
||||
"<leader>i"
|
||||
]
|
||||
},
|
||||
"input_clear": {
|
||||
"type": "string",
|
||||
"description": "Clear input field"
|
||||
"default": "ctrl+c",
|
||||
"description": "Clear input field\n\ndefault: `ctrl+c`",
|
||||
"examples": [
|
||||
"ctrl+c"
|
||||
]
|
||||
},
|
||||
"input_paste": {
|
||||
"type": "string",
|
||||
"description": "Paste from clipboard"
|
||||
"default": "ctrl+v",
|
||||
"description": "Paste from clipboard\n\ndefault: `ctrl+v`",
|
||||
"examples": [
|
||||
"ctrl+v"
|
||||
]
|
||||
},
|
||||
"input_submit": {
|
||||
"type": "string",
|
||||
"description": "Submit input"
|
||||
"default": "enter",
|
||||
"description": "Submit input\n\ndefault: `enter`",
|
||||
"examples": [
|
||||
"enter"
|
||||
]
|
||||
},
|
||||
"input_newline": {
|
||||
"type": "string",
|
||||
"description": "Insert newline in input"
|
||||
},
|
||||
"history_previous": {
|
||||
"type": "string",
|
||||
"description": "Navigate to previous history item"
|
||||
},
|
||||
"history_next": {
|
||||
"type": "string",
|
||||
"description": "Navigate to next history item"
|
||||
"default": "shift+enter,ctrl+j",
|
||||
"description": "Insert newline in input\n\ndefault: `shift+enter,ctrl+j`",
|
||||
"examples": [
|
||||
"shift+enter,ctrl+j"
|
||||
]
|
||||
},
|
||||
"messages_page_up": {
|
||||
"type": "string",
|
||||
"description": "Scroll messages up by one page"
|
||||
"default": "pgup",
|
||||
"description": "Scroll messages up by one page\n\ndefault: `pgup`",
|
||||
"examples": [
|
||||
"pgup"
|
||||
]
|
||||
},
|
||||
"messages_page_down": {
|
||||
"type": "string",
|
||||
"description": "Scroll messages down by one page"
|
||||
"default": "pgdown",
|
||||
"description": "Scroll messages down by one page\n\ndefault: `pgdown`",
|
||||
"examples": [
|
||||
"pgdown"
|
||||
]
|
||||
},
|
||||
"messages_half_page_up": {
|
||||
"type": "string",
|
||||
"description": "Scroll messages up by half page"
|
||||
"default": "ctrl+alt+u",
|
||||
"description": "Scroll messages up by half page\n\ndefault: `ctrl+alt+u`",
|
||||
"examples": [
|
||||
"ctrl+alt+u"
|
||||
]
|
||||
},
|
||||
"messages_half_page_down": {
|
||||
"type": "string",
|
||||
"description": "Scroll messages down by half page"
|
||||
"default": "ctrl+alt+d",
|
||||
"description": "Scroll messages down by half page\n\ndefault: `ctrl+alt+d`",
|
||||
"examples": [
|
||||
"ctrl+alt+d"
|
||||
]
|
||||
},
|
||||
"messages_previous": {
|
||||
"type": "string",
|
||||
"description": "Navigate to previous message"
|
||||
"default": "ctrl+up",
|
||||
"description": "Navigate to previous message\n\ndefault: `ctrl+up`",
|
||||
"examples": [
|
||||
"ctrl+up"
|
||||
]
|
||||
},
|
||||
"messages_next": {
|
||||
"type": "string",
|
||||
"description": "Navigate to next message"
|
||||
"default": "ctrl+down",
|
||||
"description": "Navigate to next message\n\ndefault: `ctrl+down`",
|
||||
"examples": [
|
||||
"ctrl+down"
|
||||
]
|
||||
},
|
||||
"messages_first": {
|
||||
"type": "string",
|
||||
"description": "Navigate to first message"
|
||||
"default": "ctrl+g",
|
||||
"description": "Navigate to first message\n\ndefault: `ctrl+g`",
|
||||
"examples": [
|
||||
"ctrl+g"
|
||||
]
|
||||
},
|
||||
"messages_last": {
|
||||
"type": "string",
|
||||
"description": "Navigate to last message"
|
||||
"default": "ctrl+alt+g",
|
||||
"description": "Navigate to last message\n\ndefault: `ctrl+alt+g`",
|
||||
"examples": [
|
||||
"ctrl+alt+g"
|
||||
]
|
||||
},
|
||||
"messages_layout_toggle": {
|
||||
"type": "string",
|
||||
"default": "<leader>p",
|
||||
"description": "Toggle layout\n\ndefault: `<leader>p`",
|
||||
"examples": [
|
||||
"<leader>p"
|
||||
]
|
||||
},
|
||||
"messages_copy": {
|
||||
"type": "string",
|
||||
"default": "<leader>y",
|
||||
"description": "Copy message\n\ndefault: `<leader>y`",
|
||||
"examples": [
|
||||
"<leader>y"
|
||||
]
|
||||
},
|
||||
"messages_revert": {
|
||||
"type": "string",
|
||||
"default": "<leader>r",
|
||||
"description": "Revert message\n\ndefault: `<leader>r`",
|
||||
"examples": [
|
||||
"<leader>r"
|
||||
]
|
||||
},
|
||||
"app_exit": {
|
||||
"type": "string",
|
||||
"description": "Exit the application"
|
||||
"default": "ctrl+c,<leader>q",
|
||||
"description": "Exit the application\n\ndefault: `ctrl+c,<leader>q`",
|
||||
"examples": [
|
||||
"ctrl+c,<leader>q"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
@@ -171,9 +327,15 @@
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"release_date": {
|
||||
"type": "string"
|
||||
},
|
||||
"attachment": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -202,7 +364,10 @@
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": ["input", "output"],
|
||||
"required": [
|
||||
"input",
|
||||
"output"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"limit": {
|
||||
@@ -215,12 +380,12 @@
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": ["context", "output"],
|
||||
"required": [
|
||||
"context",
|
||||
"output"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"options": {
|
||||
"type": "object",
|
||||
"additionalProperties": {}
|
||||
@@ -234,7 +399,9 @@
|
||||
"additionalProperties": {}
|
||||
}
|
||||
},
|
||||
"required": ["models"],
|
||||
"required": [
|
||||
"models"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"description": "Custom provider configurations and model overrides"
|
||||
@@ -270,7 +437,10 @@
|
||||
"description": "Enable or disable the MCP server on startup"
|
||||
}
|
||||
},
|
||||
"required": ["type", "command"],
|
||||
"required": [
|
||||
"type",
|
||||
"command"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
@@ -290,7 +460,10 @@
|
||||
"description": "Enable or disable the MCP server on startup"
|
||||
}
|
||||
},
|
||||
"required": ["type", "url"],
|
||||
"required": [
|
||||
"type",
|
||||
"url"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
@@ -330,7 +503,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["command"],
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
@@ -353,7 +528,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["command"],
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
@@ -366,4 +543,4 @@
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"$schema": "http://json-schema.org/draft-07/schema#"
|
||||
}
|
||||
}
|
||||
@@ -57,8 +57,7 @@ for (const [os, arch] of targets) {
|
||||
2,
|
||||
),
|
||||
)
|
||||
if (!dry)
|
||||
await $`cd dist/${name} && bun publish --access public --tag ${npmTag}`
|
||||
if (!dry) await $`cd dist/${name} && bun publish --access public --tag ${npmTag}`
|
||||
optionalDependencies[name] = version
|
||||
}
|
||||
|
||||
@@ -82,8 +81,7 @@ await Bun.file(`./dist/${pkg.name}/package.json`).write(
|
||||
2,
|
||||
),
|
||||
)
|
||||
if (!dry)
|
||||
await $`cd ./dist/${pkg.name} && bun publish --access public --tag ${npmTag}`
|
||||
if (!dry) await $`cd ./dist/${pkg.name} && bun publish --access public --tag ${npmTag}`
|
||||
|
||||
if (!snapshot) {
|
||||
// Github Release
|
||||
@@ -91,15 +89,11 @@ if (!snapshot) {
|
||||
await $`cd dist/${key}/bin && zip -r ../../${key}.zip *`
|
||||
}
|
||||
|
||||
const previous = await fetch(
|
||||
"https://api.github.com/repos/sst/opencode/releases/latest",
|
||||
)
|
||||
const previous = await fetch("https://api.github.com/repos/sst/opencode/releases/latest")
|
||||
.then((res) => res.json())
|
||||
.then((data) => data.tag_name)
|
||||
|
||||
const commits = await fetch(
|
||||
`https://api.github.com/repos/sst/opencode/compare/${previous}...HEAD`,
|
||||
)
|
||||
const commits = await fetch(`https://api.github.com/repos/sst/opencode/compare/${previous}...HEAD`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => data.commits || [])
|
||||
|
||||
@@ -117,26 +111,13 @@ if (!snapshot) {
|
||||
})
|
||||
.join("\n")
|
||||
|
||||
if (!dry)
|
||||
await $`gh release create v${version} --title "v${version}" --notes ${notes} ./dist/*.zip`
|
||||
if (!dry) await $`gh release create v${version} --title "v${version}" --notes ${notes} ./dist/*.zip`
|
||||
|
||||
// Calculate SHA values
|
||||
const arm64Sha =
|
||||
await $`sha256sum ./dist/opencode-linux-arm64.zip | cut -d' ' -f1`
|
||||
.text()
|
||||
.then((x) => x.trim())
|
||||
const x64Sha =
|
||||
await $`sha256sum ./dist/opencode-linux-x64.zip | cut -d' ' -f1`
|
||||
.text()
|
||||
.then((x) => x.trim())
|
||||
const macX64Sha =
|
||||
await $`sha256sum ./dist/opencode-darwin-x64.zip | cut -d' ' -f1`
|
||||
.text()
|
||||
.then((x) => x.trim())
|
||||
const macArm64Sha =
|
||||
await $`sha256sum ./dist/opencode-darwin-arm64.zip | cut -d' ' -f1`
|
||||
.text()
|
||||
.then((x) => x.trim())
|
||||
const arm64Sha = await $`sha256sum ./dist/opencode-linux-arm64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
|
||||
const x64Sha = await $`sha256sum ./dist/opencode-linux-x64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
|
||||
const macX64Sha = await $`sha256sum ./dist/opencode-darwin-x64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
|
||||
const macArm64Sha = await $`sha256sum ./dist/opencode-darwin-arm64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
|
||||
|
||||
// AUR package
|
||||
const pkgbuild = [
|
||||
@@ -170,9 +151,7 @@ if (!snapshot) {
|
||||
for (const pkg of ["opencode", "opencode-bin"]) {
|
||||
await $`rm -rf ./dist/aur-${pkg}`
|
||||
await $`git clone ssh://aur@aur.archlinux.org/${pkg}.git ./dist/aur-${pkg}`
|
||||
await Bun.file(`./dist/aur-${pkg}/PKGBUILD`).write(
|
||||
pkgbuild.replace("${pkg}", pkg),
|
||||
)
|
||||
await Bun.file(`./dist/aur-${pkg}/PKGBUILD`).write(pkgbuild.replace("${pkg}", pkg))
|
||||
await $`cd ./dist/aur-${pkg} && makepkg --printsrcinfo > .SRCINFO`
|
||||
await $`cd ./dist/aur-${pkg} && git add PKGBUILD .SRCINFO`
|
||||
await $`cd ./dist/aur-${pkg} && git commit -m "Update to v${version}"`
|
||||
|
||||
@@ -4,5 +4,30 @@ import "zod-openapi/extend"
|
||||
import { Config } from "../src/config/config"
|
||||
import { zodToJsonSchema } from "zod-to-json-schema"
|
||||
|
||||
const result = zodToJsonSchema(Config.Info)
|
||||
const result = zodToJsonSchema(Config.Info, {
|
||||
/**
|
||||
* We'll use the `default` values of the field as the only value in `examples`.
|
||||
* This will ensure no docs are needed to be read, as the configuration is
|
||||
* self-documenting.
|
||||
*
|
||||
* See https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.9.5
|
||||
*/
|
||||
postProcess(jsonSchema) {
|
||||
const schema = jsonSchema as typeof jsonSchema & {
|
||||
examples?: unknown[]
|
||||
}
|
||||
if (schema && typeof schema === "object" && "type" in schema && schema.type === "string" && schema?.default) {
|
||||
if (!schema.examples) {
|
||||
schema.examples = [schema.default]
|
||||
}
|
||||
|
||||
schema.description = [schema.description || "", `default: \`${schema.default}\``]
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
.trim()
|
||||
}
|
||||
|
||||
return jsonSchema
|
||||
},
|
||||
})
|
||||
await Bun.write("config.schema.json", JSON.stringify(result, null, 2))
|
||||
|
||||
@@ -45,23 +45,14 @@ export namespace App {
|
||||
}
|
||||
|
||||
export const provideExisting = ctx.provide
|
||||
export async function provide<T>(
|
||||
input: Input,
|
||||
cb: (app: App.Info) => Promise<T>,
|
||||
) {
|
||||
export async function provide<T>(input: Input, cb: (app: App.Info) => Promise<T>) {
|
||||
log.info("creating", {
|
||||
cwd: input.cwd,
|
||||
})
|
||||
const git = await Filesystem.findUp(".git", input.cwd).then(([x]) =>
|
||||
x ? path.dirname(x) : undefined,
|
||||
)
|
||||
const git = await Filesystem.findUp(".git", input.cwd).then(([x]) => (x ? path.dirname(x) : undefined))
|
||||
log.info("git", { git })
|
||||
|
||||
const data = path.join(
|
||||
Global.Path.data,
|
||||
"project",
|
||||
git ? directory(git) : "global",
|
||||
)
|
||||
const data = path.join(Global.Path.data, "project", git ? directory(git) : "global")
|
||||
const stateFile = Bun.file(path.join(data, APP_JSON))
|
||||
const state = (await stateFile.json().catch(() => ({}))) as {
|
||||
initialized: number
|
||||
|
||||
@@ -10,14 +10,8 @@ export namespace AuthAnthropic {
|
||||
url.searchParams.set("code", "true")
|
||||
url.searchParams.set("client_id", CLIENT_ID)
|
||||
url.searchParams.set("response_type", "code")
|
||||
url.searchParams.set(
|
||||
"redirect_uri",
|
||||
"https://console.anthropic.com/oauth/code/callback",
|
||||
)
|
||||
url.searchParams.set(
|
||||
"scope",
|
||||
"org:create_api_key user:profile user:inference",
|
||||
)
|
||||
url.searchParams.set("redirect_uri", "https://console.anthropic.com/oauth/code/callback")
|
||||
url.searchParams.set("scope", "org:create_api_key user:profile user:inference")
|
||||
url.searchParams.set("code_challenge", pkce.challenge)
|
||||
url.searchParams.set("code_challenge_method", "S256")
|
||||
url.searchParams.set("state", pkce.verifier)
|
||||
@@ -57,20 +51,17 @@ export namespace AuthAnthropic {
|
||||
const info = await Auth.get("anthropic")
|
||||
if (!info || info.type !== "oauth") return
|
||||
if (info.access && info.expires > Date.now()) return info.access
|
||||
const response = await fetch(
|
||||
"https://console.anthropic.com/v1/oauth/token",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: info.refresh,
|
||||
client_id: CLIENT_ID,
|
||||
}),
|
||||
const response = await fetch("https://console.anthropic.com/v1/oauth/token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
body: JSON.stringify({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: info.refresh,
|
||||
client_id: CLIENT_ID,
|
||||
}),
|
||||
})
|
||||
if (!response.ok) return
|
||||
const json = await response.json()
|
||||
await Auth.set("anthropic", {
|
||||
|
||||
@@ -4,9 +4,7 @@ import path from "path"
|
||||
|
||||
export const AuthCopilot = lazy(async () => {
|
||||
const file = Bun.file(path.join(Global.Path.state, "plugin", "copilot.ts"))
|
||||
const response = fetch(
|
||||
"https://raw.githubusercontent.com/sst/opencode-github-copilot/refs/heads/main/auth.ts",
|
||||
)
|
||||
const response = fetch("https://raw.githubusercontent.com/sst/opencode-github-copilot/refs/heads/main/auth.ts")
|
||||
.then((x) => Bun.write(file, x))
|
||||
.catch(() => {})
|
||||
|
||||
|
||||
@@ -122,10 +122,7 @@ export namespace AuthGithubCopilot {
|
||||
return tokenData.token
|
||||
}
|
||||
|
||||
export const DeviceCodeError = NamedError.create(
|
||||
"DeviceCodeError",
|
||||
z.object({}),
|
||||
)
|
||||
export const DeviceCodeError = NamedError.create("DeviceCodeError", z.object({}))
|
||||
|
||||
export const TokenExchangeError = NamedError.create(
|
||||
"TokenExchangeError",
|
||||
|
||||
@@ -8,10 +8,7 @@ import { readableStreamToText } from "bun"
|
||||
export namespace BunProc {
|
||||
const log = Log.create({ service: "bun" })
|
||||
|
||||
export async function run(
|
||||
cmd: string[],
|
||||
options?: Bun.SpawnOptions.OptionsObject<any, any, any>,
|
||||
) {
|
||||
export async function run(cmd: string[], options?: Bun.SpawnOptions.OptionsObject<any, any, any>) {
|
||||
log.info("running", {
|
||||
cmd: [which(), ...cmd],
|
||||
...options,
|
||||
@@ -26,9 +23,17 @@ export namespace BunProc {
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
})
|
||||
const code = await result.exited;
|
||||
const stdout = result.stdout ? typeof result.stdout === "number" ? result.stdout : await readableStreamToText(result.stdout) : undefined
|
||||
const stderr = result.stderr ? typeof result.stderr === "number" ? result.stderr : await readableStreamToText(result.stderr) : undefined
|
||||
const code = await result.exited
|
||||
const stdout = result.stdout
|
||||
? typeof result.stdout === "number"
|
||||
? result.stdout
|
||||
: await readableStreamToText(result.stdout)
|
||||
: undefined
|
||||
const stderr = result.stderr
|
||||
? typeof result.stderr === "number"
|
||||
? result.stderr
|
||||
: await readableStreamToText(result.stderr)
|
||||
: undefined
|
||||
log.info("done", {
|
||||
code,
|
||||
stdout,
|
||||
@@ -61,7 +66,7 @@ export namespace BunProc {
|
||||
if (parsed.dependencies[pkg] === version) return mod
|
||||
parsed.dependencies[pkg] = version
|
||||
await Bun.write(pkgjson, JSON.stringify(parsed, null, 2))
|
||||
await BunProc.run(["install", "--registry=https://registry.npmjs.org"], {
|
||||
await BunProc.run(["install", "--cwd", Global.Path.cache, "--registry=https://registry.npmjs.org"], {
|
||||
cwd: Global.Path.cache,
|
||||
}).catch((e) => {
|
||||
throw new InstallFailedError(
|
||||
|
||||
@@ -18,10 +18,7 @@ export namespace Bus {
|
||||
|
||||
const registry = new Map<string, EventDefinition>()
|
||||
|
||||
export function event<Type extends string, Properties extends ZodType>(
|
||||
type: Type,
|
||||
properties: Properties,
|
||||
) {
|
||||
export function event<Type extends string, Properties extends ZodType>(type: Type, properties: Properties) {
|
||||
const result = {
|
||||
type,
|
||||
properties,
|
||||
@@ -72,10 +69,7 @@ export namespace Bus {
|
||||
|
||||
export function subscribe<Definition extends EventDefinition>(
|
||||
def: Definition,
|
||||
callback: (event: {
|
||||
type: Definition["type"]
|
||||
properties: z.infer<Definition["properties"]>
|
||||
}) => void,
|
||||
callback: (event: { type: Definition["type"]; properties: z.infer<Definition["properties"]> }) => void,
|
||||
) {
|
||||
return raw(def.type, callback)
|
||||
}
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
import { App } from "../app/app"
|
||||
import { ConfigHooks } from "../config/hooks"
|
||||
import { FileWatcher } from "../file/watch"
|
||||
import { Format } from "../format"
|
||||
import { LSP } from "../lsp"
|
||||
import { Share } from "../share/share"
|
||||
|
||||
export async function bootstrap<T>(
|
||||
input: App.Input,
|
||||
cb: (app: App.Info) => Promise<T>,
|
||||
) {
|
||||
export async function bootstrap<T>(input: App.Input, cb: (app: App.Info) => Promise<T>) {
|
||||
return App.provide(input, async (app) => {
|
||||
Share.init()
|
||||
Format.init()
|
||||
ConfigHooks.init()
|
||||
LSP.init()
|
||||
FileWatcher.init()
|
||||
|
||||
return cb(app)
|
||||
})
|
||||
|
||||
@@ -15,11 +15,7 @@ export const AuthCommand = cmd({
|
||||
command: "auth",
|
||||
describe: "manage credentials",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.command(AuthLoginCommand)
|
||||
.command(AuthLogoutCommand)
|
||||
.command(AuthListCommand)
|
||||
.demandCommand(),
|
||||
yargs.command(AuthLoginCommand).command(AuthLogoutCommand).command(AuthListCommand).demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
@@ -31,9 +27,7 @@ export const AuthListCommand = cmd({
|
||||
UI.empty()
|
||||
const authPath = path.join(Global.Path.data, "auth.json")
|
||||
const homedir = os.homedir()
|
||||
const displayPath = authPath.startsWith(homedir)
|
||||
? authPath.replace(homedir, "~")
|
||||
: authPath
|
||||
const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath
|
||||
prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
|
||||
const results = await Auth.all().then((x) => Object.entries(x))
|
||||
const database = await ModelsDev.get()
|
||||
@@ -114,8 +108,7 @@ export const AuthLoginCommand = cmd({
|
||||
if (provider === "other") {
|
||||
provider = await prompts.text({
|
||||
message: "Enter provider id",
|
||||
validate: (x) =>
|
||||
x.match(/^[a-z-]+$/) ? undefined : "a-z and hyphens only",
|
||||
validate: (x) => (x.match(/^[a-z-]+$/) ? undefined : "a-z and hyphens only"),
|
||||
})
|
||||
if (prompts.isCancel(provider)) throw new UI.CancelledError()
|
||||
provider = provider.replace(/^@ai-sdk\//, "")
|
||||
@@ -186,17 +179,13 @@ export const AuthLoginCommand = cmd({
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
const deviceInfo = await copilot.authorize()
|
||||
|
||||
prompts.note(
|
||||
`Please visit: ${deviceInfo.verification}\nEnter code: ${deviceInfo.user}`,
|
||||
)
|
||||
prompts.note(`Please visit: ${deviceInfo.verification}\nEnter code: ${deviceInfo.user}`)
|
||||
|
||||
const spinner = prompts.spinner()
|
||||
spinner.start("Waiting for authorization...")
|
||||
|
||||
while (true) {
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, deviceInfo.interval * 1000),
|
||||
)
|
||||
await new Promise((resolve) => setTimeout(resolve, deviceInfo.interval * 1000))
|
||||
const response = await copilot.poll(deviceInfo.device)
|
||||
if (response.status === "pending") continue
|
||||
if (response.status === "success") {
|
||||
@@ -248,12 +237,7 @@ export const AuthLogoutCommand = cmd({
|
||||
const providerID = await prompts.select({
|
||||
message: "Select provider",
|
||||
options: credentials.map(([key, value]) => ({
|
||||
label:
|
||||
(database[key]?.name || key) +
|
||||
UI.Style.TEXT_DIM +
|
||||
" (" +
|
||||
value.type +
|
||||
")",
|
||||
label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
|
||||
value: key,
|
||||
})),
|
||||
})
|
||||
|
||||
@@ -31,7 +31,6 @@ const FileStatusCommand = cmd({
|
||||
|
||||
export const FileCommand = cmd({
|
||||
command: "file",
|
||||
builder: (yargs) =>
|
||||
yargs.command(FileReadCommand).command(FileStatusCommand).demandCommand(),
|
||||
builder: (yargs) => yargs.command(FileReadCommand).command(FileStatusCommand).demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
@@ -17,9 +17,7 @@ export const DebugCommand = cmd({
|
||||
command: "wait",
|
||||
async handler() {
|
||||
await bootstrap({ cwd: process.cwd() }, async () => {
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, 1_000 * 60 * 60 * 24),
|
||||
)
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_000 * 60 * 60 * 24))
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -5,15 +5,13 @@ import { Log } from "../../../util/log"
|
||||
|
||||
export const LSPCommand = cmd({
|
||||
command: "lsp",
|
||||
builder: (yargs) =>
|
||||
yargs.command(DiagnosticsCommand).command(SymbolsCommand).demandCommand(),
|
||||
builder: (yargs) => yargs.command(DiagnosticsCommand).command(SymbolsCommand).demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
const DiagnosticsCommand = cmd({
|
||||
command: "diagnostics <file>",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("file", { type: "string", demandOption: true }),
|
||||
builder: (yargs) => yargs.positional("file", { type: "string", demandOption: true }),
|
||||
async handler(args) {
|
||||
await bootstrap({ cwd: process.cwd() }, async () => {
|
||||
await LSP.touchFile(args.file, true)
|
||||
@@ -24,11 +22,9 @@ const DiagnosticsCommand = cmd({
|
||||
|
||||
export const SymbolsCommand = cmd({
|
||||
command: "symbols <query>",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("query", { type: "string", demandOption: true }),
|
||||
builder: (yargs) => yargs.positional("query", { type: "string", demandOption: true }),
|
||||
async handler(args) {
|
||||
await bootstrap({ cwd: process.cwd() }, async () => {
|
||||
await LSP.touchFile("./src/index.ts", true)
|
||||
using _ = Log.Default.time("symbols")
|
||||
const results = await LSP.workspaceSymbol(args.query)
|
||||
console.log(JSON.stringify(results, null, 2))
|
||||
|
||||
@@ -5,12 +5,7 @@ import { cmd } from "../cmd"
|
||||
|
||||
export const RipgrepCommand = cmd({
|
||||
command: "rg",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.command(TreeCommand)
|
||||
.command(FilesCommand)
|
||||
.command(SearchCommand)
|
||||
.demandCommand(),
|
||||
builder: (yargs) => yargs.command(TreeCommand).command(FilesCommand).command(SearchCommand).demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
@@ -50,7 +45,7 @@ const FilesCommand = cmd({
|
||||
const files = await Ripgrep.files({
|
||||
cwd: app.path.cwd,
|
||||
query: args.query,
|
||||
glob: args.glob,
|
||||
glob: args.glob ? [args.glob] : undefined,
|
||||
limit: args.limit,
|
||||
})
|
||||
console.log(files.join("\n"))
|
||||
|
||||
@@ -4,11 +4,7 @@ import { cmd } from "../cmd"
|
||||
|
||||
export const SnapshotCommand = cmd({
|
||||
command: "snapshot",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.command(SnapshotCreateCommand)
|
||||
.command(SnapshotRestoreCommand)
|
||||
.demandCommand(),
|
||||
builder: (yargs) => yargs.command(SnapshotCreateCommand).command(SnapshotRestoreCommand).demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
|
||||
@@ -10,9 +10,6 @@ export const GenerateCommand = {
|
||||
const dir = "gen"
|
||||
await fs.rmdir(dir, { recursive: true }).catch(() => {})
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
await Bun.write(
|
||||
path.join(dir, "openapi.json"),
|
||||
JSON.stringify(specs, null, 2),
|
||||
)
|
||||
await Bun.write(path.join(dir, "openapi.json"), JSON.stringify(specs, null, 2))
|
||||
},
|
||||
} satisfies CommandModule
|
||||
|
||||
@@ -2,12 +2,12 @@ import type { Argv } from "yargs"
|
||||
import { Bus } from "../../bus"
|
||||
import { Provider } from "../../provider/provider"
|
||||
import { Session } from "../../session"
|
||||
import { Message } from "../../session/message"
|
||||
import { UI } from "../ui"
|
||||
import { cmd } from "./cmd"
|
||||
import { Flag } from "../../flag/flag"
|
||||
import { Config } from "../../config/config"
|
||||
import { bootstrap } from "../bootstrap"
|
||||
import { MessageV2 } from "../../session/message-v2"
|
||||
|
||||
const TOOL: Record<string, [string, string]> = {
|
||||
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
|
||||
@@ -54,7 +54,10 @@ export const RunCommand = cmd({
|
||||
})
|
||||
},
|
||||
handler: async (args) => {
|
||||
const message = args.message.join(" ")
|
||||
let message = args.message.join(" ")
|
||||
|
||||
if (!process.stdin.isTTY) message += "\n" + (await Bun.stdin.text())
|
||||
|
||||
await bootstrap({ cwd: process.cwd() }, async () => {
|
||||
const session = await (async () => {
|
||||
if (args.continue) {
|
||||
@@ -78,27 +81,19 @@ export const RunCommand = cmd({
|
||||
UI.empty()
|
||||
UI.println(UI.logo())
|
||||
UI.empty()
|
||||
UI.println(UI.Style.TEXT_NORMAL_BOLD + "> ", message)
|
||||
const displayMessage = message.length > 300 ? message.slice(0, 300) + "..." : message
|
||||
UI.println(UI.Style.TEXT_NORMAL_BOLD + "> ", displayMessage)
|
||||
UI.empty()
|
||||
|
||||
const cfg = await Config.get()
|
||||
if (cfg.autoshare || Flag.OPENCODE_AUTO_SHARE || args.share) {
|
||||
await Session.share(session.id)
|
||||
UI.println(
|
||||
UI.Style.TEXT_INFO_BOLD +
|
||||
"~ https://opencode.ai/s/" +
|
||||
session.id.slice(-8),
|
||||
)
|
||||
UI.println(UI.Style.TEXT_INFO_BOLD + "~ https://opencode.ai/s/" + session.id.slice(-8))
|
||||
}
|
||||
UI.empty()
|
||||
|
||||
const { providerID, modelID } = args.model
|
||||
? Provider.parseModel(args.model)
|
||||
: await Provider.defaultModel()
|
||||
UI.println(
|
||||
UI.Style.TEXT_NORMAL_BOLD + "@ ",
|
||||
UI.Style.TEXT_NORMAL + `${providerID}/${modelID}`,
|
||||
)
|
||||
const { providerID, modelID } = args.model ? Provider.parseModel(args.model) : await Provider.defaultModel()
|
||||
UI.println(UI.Style.TEXT_NORMAL_BOLD + "@ ", UI.Style.TEXT_NORMAL + `${providerID}/${modelID}`)
|
||||
UI.empty()
|
||||
|
||||
function printEvent(color: string, type: string, title: string) {
|
||||
@@ -110,24 +105,13 @@ export const RunCommand = cmd({
|
||||
)
|
||||
}
|
||||
|
||||
Bus.subscribe(Message.Event.PartUpdated, async (evt) => {
|
||||
Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => {
|
||||
if (evt.properties.sessionID !== session.id) return
|
||||
const part = evt.properties.part
|
||||
const message = await Session.getMessage(
|
||||
evt.properties.sessionID,
|
||||
evt.properties.messageID,
|
||||
)
|
||||
|
||||
if (
|
||||
part.type === "tool-invocation" &&
|
||||
part.toolInvocation.state === "result"
|
||||
) {
|
||||
const metadata = message.metadata.tool[part.toolInvocation.toolCallId]
|
||||
const [tool, color] = TOOL[part.toolInvocation.toolName] ?? [
|
||||
part.toolInvocation.toolName,
|
||||
UI.Style.TEXT_INFO_BOLD,
|
||||
]
|
||||
printEvent(color, tool, metadata?.title || "Unknown")
|
||||
if (part.type === "tool" && part.state.status === "completed") {
|
||||
const [tool, color] = TOOL[part.tool] ?? [part.tool, UI.Style.TEXT_INFO_BOLD]
|
||||
printEvent(color, tool, part.state.title || "Unknown")
|
||||
}
|
||||
|
||||
if (part.type === "text") {
|
||||
@@ -141,6 +125,20 @@ export const RunCommand = cmd({
|
||||
}
|
||||
})
|
||||
|
||||
let errorMsg: string | undefined
|
||||
Bus.subscribe(Session.Event.Error, async (evt) => {
|
||||
const { sessionID, error } = evt.properties
|
||||
if (sessionID !== session.id || !error) return
|
||||
let err = String(error.name)
|
||||
|
||||
if ("data" in error && error.data && "message" in error.data) {
|
||||
err = error.data.message
|
||||
}
|
||||
errorMsg = errorMsg ? errorMsg + "\n" + err : err
|
||||
|
||||
UI.error(err)
|
||||
})
|
||||
|
||||
const result = await Session.chat({
|
||||
sessionID: session.id,
|
||||
providerID,
|
||||
@@ -156,6 +154,7 @@ export const RunCommand = cmd({
|
||||
if (isPiped) {
|
||||
const match = result.parts.findLast((x) => x.type === "text")
|
||||
if (match) process.stdout.write(match.text)
|
||||
if (errorMsg) process.stdout.write(errorMsg)
|
||||
}
|
||||
UI.empty()
|
||||
})
|
||||
|
||||
@@ -38,9 +38,7 @@ export const ServeCommand = cmd({
|
||||
hostname,
|
||||
})
|
||||
|
||||
console.log(
|
||||
`opencode server listening on http://${server.hostname}:${server.port}`,
|
||||
)
|
||||
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
|
||||
|
||||
await new Promise(() => {})
|
||||
|
||||
|
||||
@@ -10,15 +10,27 @@ import { Installation } from "../../installation"
|
||||
import { Config } from "../../config/config"
|
||||
import { Bus } from "../../bus"
|
||||
import { Log } from "../../util/log"
|
||||
import { FileWatcher } from "../../file/watch"
|
||||
|
||||
export const TuiCommand = cmd({
|
||||
command: "$0 [project]",
|
||||
describe: "start opencode tui",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("project", {
|
||||
type: "string",
|
||||
describe: "path to start opencode in",
|
||||
}),
|
||||
yargs
|
||||
.positional("project", {
|
||||
type: "string",
|
||||
describe: "path to start opencode in",
|
||||
})
|
||||
.option("model", {
|
||||
type: "string",
|
||||
alias: ["m"],
|
||||
describe: "model to use in the format of provider/model",
|
||||
})
|
||||
.option("prompt", {
|
||||
alias: ["p"],
|
||||
type: "string",
|
||||
describe: "prompt to use",
|
||||
}),
|
||||
handler: async (args) => {
|
||||
while (true) {
|
||||
const cwd = args.project ? path.resolve(args.project) : process.cwd()
|
||||
@@ -29,6 +41,7 @@ export const TuiCommand = cmd({
|
||||
return
|
||||
}
|
||||
const result = await bootstrap({ cwd }, async (app) => {
|
||||
FileWatcher.init()
|
||||
const providers = await Provider.list()
|
||||
if (Object.keys(providers).length === 0) {
|
||||
return "needs_provider"
|
||||
@@ -40,9 +53,7 @@ export const TuiCommand = cmd({
|
||||
})
|
||||
|
||||
let cmd = ["go", "run", "./main.go"]
|
||||
let cwd = Bun.fileURLToPath(
|
||||
new URL("../../../../tui/cmd/opencode", import.meta.url),
|
||||
)
|
||||
let cwd = Bun.fileURLToPath(new URL("../../../../tui/cmd/opencode", import.meta.url))
|
||||
if (Bun.embeddedFiles.length > 0) {
|
||||
const blob = Bun.embeddedFiles[0] as File
|
||||
let binaryName = blob.name
|
||||
@@ -62,13 +73,18 @@ export const TuiCommand = cmd({
|
||||
cmd,
|
||||
})
|
||||
const proc = Bun.spawn({
|
||||
cmd: [...cmd, ...process.argv.slice(2)],
|
||||
cmd: [
|
||||
...cmd,
|
||||
...(args.model ? ["--model", args.model] : []),
|
||||
...(args.prompt ? ["--prompt", args.prompt] : []),
|
||||
],
|
||||
cwd,
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
stdin: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
CGO_ENABLED: "0",
|
||||
OPENCODE_SERVER: server.url.toString(),
|
||||
OPENCODE_APP_INFO: JSON.stringify(app),
|
||||
},
|
||||
|
||||
@@ -27,9 +27,7 @@ export const UpgradeCommand = {
|
||||
const detectedMethod = await Installation.method()
|
||||
const method = (args.method as Installation.Method) ?? detectedMethod
|
||||
if (method === "unknown") {
|
||||
prompts.log.error(
|
||||
`opencode is installed to ${process.execPath} and seems to be managed by a package manager`,
|
||||
)
|
||||
prompts.log.error(`opencode is installed to ${process.execPath} and seems to be managed by a package manager`)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
@@ -37,9 +35,7 @@ export const UpgradeCommand = {
|
||||
const target = args.target ?? (await Installation.latest())
|
||||
|
||||
if (Installation.VERSION === target) {
|
||||
prompts.log.warn(
|
||||
`opencode upgrade skipped: ${target} is already installed`,
|
||||
)
|
||||
prompts.log.warn(`opencode upgrade skipped: ${target} is already installed`)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
@@ -50,8 +46,7 @@ export const UpgradeCommand = {
|
||||
const err = await Installation.upgrade(method, target).catch((err) => err)
|
||||
if (err) {
|
||||
spinner.stop("Upgrade failed")
|
||||
if (err instanceof Installation.UpgradeFailedError)
|
||||
prompts.log.error(err.data.stderr)
|
||||
if (err instanceof Installation.UpgradeFailedError) prompts.log.error(err.data.stderr)
|
||||
else if (err instanceof Error) prompts.log.error(err.message)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
|
||||
@@ -5,14 +5,11 @@ import { UI } from "./ui"
|
||||
export function FormatError(input: unknown) {
|
||||
if (MCP.Failed.isInstance(input))
|
||||
return `MCP server "${input.data.name}" failed. Note, opencode does not support MCP authentication yet.`
|
||||
if (Config.JsonError.isInstance(input))
|
||||
return `Config file at ${input.data.path} is not valid JSON`
|
||||
if (Config.JsonError.isInstance(input)) return `Config file at ${input.data.path} is not valid JSON`
|
||||
if (Config.InvalidError.isInstance(input))
|
||||
return [
|
||||
`Config file at ${input.data.path} is invalid`,
|
||||
...(input.data.issues?.map(
|
||||
(issue) => "↳ " + issue.message + " " + issue.path.join("."),
|
||||
) ?? []),
|
||||
...(input.data.issues?.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")) ?? []),
|
||||
].join("\n")
|
||||
|
||||
if (UI.CancelledError.isInstance(input)) return ""
|
||||
|
||||
@@ -29,18 +29,12 @@ export namespace Config {
|
||||
export const McpLocal = z
|
||||
.object({
|
||||
type: z.literal("local").describe("Type of MCP server connection"),
|
||||
command: z
|
||||
.string()
|
||||
.array()
|
||||
.describe("Command and arguments to run the MCP server"),
|
||||
command: z.string().array().describe("Command and arguments to run the MCP server"),
|
||||
environment: z
|
||||
.record(z.string(), z.string())
|
||||
.optional()
|
||||
.describe("Environment variables to set when running the MCP server"),
|
||||
enabled: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Enable or disable the MCP server on startup"),
|
||||
enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"),
|
||||
})
|
||||
.strict()
|
||||
.openapi({
|
||||
@@ -51,10 +45,7 @@ export namespace Config {
|
||||
.object({
|
||||
type: z.literal("remote").describe("Type of MCP server connection"),
|
||||
url: z.string().describe("URL of the remote MCP server"),
|
||||
enabled: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Enable or disable the MCP server on startup"),
|
||||
enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"),
|
||||
})
|
||||
.strict()
|
||||
.openapi({
|
||||
@@ -66,69 +57,43 @@ export namespace Config {
|
||||
|
||||
export const Keybinds = z
|
||||
.object({
|
||||
leader: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Leader key for keybind combinations"),
|
||||
help: z.string().optional().describe("Show help dialog"),
|
||||
editor_open: z.string().optional().describe("Open external editor"),
|
||||
session_new: z.string().optional().describe("Create a new session"),
|
||||
session_list: z.string().optional().describe("List all sessions"),
|
||||
session_share: z.string().optional().describe("Share current session"),
|
||||
session_interrupt: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Interrupt current session"),
|
||||
session_compact: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Toggle compact mode for session"),
|
||||
tool_details: z.string().optional().describe("Show tool details"),
|
||||
model_list: z.string().optional().describe("List available models"),
|
||||
theme_list: z.string().optional().describe("List available themes"),
|
||||
project_init: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Initialize project configuration"),
|
||||
input_clear: z.string().optional().describe("Clear input field"),
|
||||
input_paste: z.string().optional().describe("Paste from clipboard"),
|
||||
input_submit: z.string().optional().describe("Submit input"),
|
||||
input_newline: z.string().optional().describe("Insert newline in input"),
|
||||
history_previous: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Navigate to previous history item"),
|
||||
history_next: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Navigate to next history item"),
|
||||
messages_page_up: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Scroll messages up by one page"),
|
||||
messages_page_down: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Scroll messages down by one page"),
|
||||
messages_half_page_up: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Scroll messages up by half page"),
|
||||
leader: z.string().optional().default("ctrl+x").describe("Leader key for keybind combinations"),
|
||||
app_help: z.string().optional().default("<leader>h").describe("Show help dialog"),
|
||||
editor_open: z.string().optional().default("<leader>e").describe("Open external editor"),
|
||||
session_new: z.string().optional().default("<leader>n").describe("Create a new session"),
|
||||
session_list: z.string().optional().default("<leader>l").describe("List all sessions"),
|
||||
session_share: z.string().optional().default("<leader>s").describe("Share current session"),
|
||||
session_unshare: z.string().optional().default("<leader>u").describe("Unshare current session"),
|
||||
session_interrupt: z.string().optional().default("esc").describe("Interrupt current session"),
|
||||
session_compact: z.string().optional().default("<leader>c").describe("Compact the session"),
|
||||
tool_details: z.string().optional().default("<leader>d").describe("Toggle tool details"),
|
||||
model_list: z.string().optional().default("<leader>m").describe("List available models"),
|
||||
theme_list: z.string().optional().default("<leader>t").describe("List available themes"),
|
||||
file_list: z.string().optional().default("<leader>f").describe("List files"),
|
||||
file_close: z.string().optional().default("esc").describe("Close file"),
|
||||
file_search: z.string().optional().default("<leader>/").describe("Search file"),
|
||||
file_diff_toggle: z.string().optional().default("<leader>v").describe("Split/unified diff"),
|
||||
project_init: z.string().optional().default("<leader>i").describe("Create/update AGENTS.md"),
|
||||
input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"),
|
||||
input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"),
|
||||
input_submit: z.string().optional().default("enter").describe("Submit input"),
|
||||
input_newline: z.string().optional().default("shift+enter,ctrl+j").describe("Insert newline in input"),
|
||||
messages_page_up: z.string().optional().default("pgup").describe("Scroll messages up by one page"),
|
||||
messages_page_down: z.string().optional().default("pgdown").describe("Scroll messages down by one page"),
|
||||
messages_half_page_up: z.string().optional().default("ctrl+alt+u").describe("Scroll messages up by half page"),
|
||||
messages_half_page_down: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("ctrl+alt+d")
|
||||
.describe("Scroll messages down by half page"),
|
||||
messages_previous: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Navigate to previous message"),
|
||||
messages_next: z.string().optional().describe("Navigate to next message"),
|
||||
messages_first: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Navigate to first message"),
|
||||
messages_last: z.string().optional().describe("Navigate to last message"),
|
||||
app_exit: z.string().optional().describe("Exit the application"),
|
||||
messages_previous: z.string().optional().default("ctrl+up").describe("Navigate to previous message"),
|
||||
messages_next: z.string().optional().default("ctrl+down").describe("Navigate to next message"),
|
||||
messages_first: z.string().optional().default("ctrl+g").describe("Navigate to first message"),
|
||||
messages_last: z.string().optional().default("ctrl+alt+g").describe("Navigate to last message"),
|
||||
messages_layout_toggle: z.string().optional().default("<leader>p").describe("Toggle layout"),
|
||||
messages_copy: z.string().optional().default("<leader>y").describe("Copy message"),
|
||||
messages_revert: z.string().optional().default("<leader>r").describe("Revert message"),
|
||||
app_exit: z.string().optional().default("ctrl+c,<leader>q").describe("Exit the application"),
|
||||
})
|
||||
.strict()
|
||||
.openapi({
|
||||
@@ -136,33 +101,13 @@ export namespace Config {
|
||||
})
|
||||
export const Info = z
|
||||
.object({
|
||||
$schema: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("JSON schema reference for configuration validation"),
|
||||
theme: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Theme name to use for the interface"),
|
||||
$schema: z.string().optional().describe("JSON schema reference for configuration validation"),
|
||||
theme: z.string().optional().describe("Theme name to use for the interface"),
|
||||
keybinds: Keybinds.optional().describe("Custom keybind configurations"),
|
||||
autoshare: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Share newly created sessions automatically"),
|
||||
autoupdate: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Automatically update to the latest version"),
|
||||
disabled_providers: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe("Disable providers that are loaded automatically"),
|
||||
model: z
|
||||
.string()
|
||||
.describe(
|
||||
"Model to use in the format of provider/model, eg anthropic/claude-2",
|
||||
)
|
||||
.optional(),
|
||||
autoshare: z.boolean().optional().describe("Share newly created sessions automatically"),
|
||||
autoupdate: z.boolean().optional().describe("Automatically update to the latest version"),
|
||||
disabled_providers: z.array(z.string()).optional().describe("Disable providers that are loaded automatically"),
|
||||
model: z.string().describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(),
|
||||
provider: z
|
||||
.record(
|
||||
ModelsDev.Provider.partial().extend({
|
||||
@@ -172,14 +117,8 @@ export namespace Config {
|
||||
)
|
||||
.optional()
|
||||
.describe("Custom provider configurations and model overrides"),
|
||||
mcp: z
|
||||
.record(z.string(), Mcp)
|
||||
.optional()
|
||||
.describe("MCP (Model Context Protocol) server configurations"),
|
||||
instructions: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe("Additional instruction files or patterns to include"),
|
||||
mcp: z.record(z.string(), Mcp).optional().describe("MCP (Model Context Protocol) server configurations"),
|
||||
instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"),
|
||||
experimental: z
|
||||
.object({
|
||||
hook: z
|
||||
@@ -227,10 +166,7 @@ export namespace Config {
|
||||
if (provider && model) result.model = `${provider}/${model}`
|
||||
result["$schema"] = "https://opencode.ai/config.json"
|
||||
result = mergeDeep(result, rest)
|
||||
await Bun.write(
|
||||
path.join(Global.Path.config, "config.json"),
|
||||
JSON.stringify(result, null, 2),
|
||||
)
|
||||
await Bun.write(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2))
|
||||
await fs.unlink(path.join(Global.Path.config, "config"))
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
@@ -22,9 +22,7 @@ export namespace ConfigHooks {
|
||||
command: item.command,
|
||||
})
|
||||
Bun.spawn({
|
||||
cmd: item.command.map((x) =>
|
||||
x.replace("$FILE", payload.properties.file),
|
||||
),
|
||||
cmd: item.command.map((x) => x.replace("$FILE", payload.properties.file)),
|
||||
env: item.environment,
|
||||
cwd: app.path.cwd,
|
||||
stdout: "ignore",
|
||||
|
||||
@@ -45,10 +45,7 @@ export namespace Fzf {
|
||||
log.info("found", { filepath })
|
||||
return { filepath }
|
||||
}
|
||||
filepath = path.join(
|
||||
Global.Path.bin,
|
||||
"fzf" + (process.platform === "win32" ? ".exe" : ""),
|
||||
)
|
||||
filepath = path.join(Global.Path.bin, "fzf" + (process.platform === "win32" ? ".exe" : ""))
|
||||
|
||||
const file = Bun.file(filepath)
|
||||
if (!(await file.exists())) {
|
||||
@@ -56,18 +53,15 @@ export namespace Fzf {
|
||||
const arch = archMap[process.arch as keyof typeof archMap] ?? "amd64"
|
||||
|
||||
const config = PLATFORM[process.platform as keyof typeof PLATFORM]
|
||||
if (!config)
|
||||
throw new UnsupportedPlatformError({ platform: process.platform })
|
||||
if (!config) throw new UnsupportedPlatformError({ platform: process.platform })
|
||||
|
||||
const version = VERSION
|
||||
const platformName =
|
||||
process.platform === "win32" ? "windows" : process.platform
|
||||
const platformName = process.platform === "win32" ? "windows" : process.platform
|
||||
const filename = `fzf-${version}-${platformName}_${arch}.${config.extension}`
|
||||
const url = `https://github.com/junegunn/fzf/releases/download/v${version}/${filename}`
|
||||
|
||||
const response = await fetch(url)
|
||||
if (!response.ok)
|
||||
throw new DownloadFailedError({ url, status: response.status })
|
||||
if (!response.ok) throw new DownloadFailedError({ url, status: response.status })
|
||||
|
||||
const buffer = await response.arrayBuffer()
|
||||
const archivePath = path.join(Global.Path.bin, filename)
|
||||
@@ -86,14 +80,11 @@ export namespace Fzf {
|
||||
})
|
||||
}
|
||||
if (config.extension === "zip") {
|
||||
const proc = Bun.spawn(
|
||||
["unzip", "-j", archivePath, "fzf.exe", "-d", Global.Path.bin],
|
||||
{
|
||||
cwd: Global.Path.bin,
|
||||
stderr: "pipe",
|
||||
stdout: "ignore",
|
||||
},
|
||||
)
|
||||
const proc = Bun.spawn(["unzip", "-j", archivePath, "fzf.exe", "-d", Global.Path.bin], {
|
||||
cwd: Global.Path.bin,
|
||||
stderr: "pipe",
|
||||
stdout: "ignore",
|
||||
})
|
||||
await proc.exited
|
||||
if (proc.exitCode !== 0)
|
||||
throw new ExtractionFailedError({
|
||||
|
||||
@@ -24,11 +24,7 @@ export namespace File {
|
||||
const app = App.info()
|
||||
if (!app.git) return []
|
||||
|
||||
const diffOutput = await $`git diff --numstat HEAD`
|
||||
.cwd(app.path.cwd)
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.text()
|
||||
const diffOutput = await $`git diff --numstat HEAD`.cwd(app.path.cwd).quiet().nothrow().text()
|
||||
|
||||
const changedFiles = []
|
||||
|
||||
@@ -45,19 +41,13 @@ export namespace File {
|
||||
}
|
||||
}
|
||||
|
||||
const untrackedOutput = await $`git ls-files --others --exclude-standard`
|
||||
.cwd(app.path.cwd)
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.text()
|
||||
const untrackedOutput = await $`git ls-files --others --exclude-standard`.cwd(app.path.cwd).quiet().nothrow().text()
|
||||
|
||||
if (untrackedOutput.trim()) {
|
||||
const untrackedFiles = untrackedOutput.trim().split("\n")
|
||||
for (const filepath of untrackedFiles) {
|
||||
try {
|
||||
const content = await Bun.file(
|
||||
path.join(app.path.root, filepath),
|
||||
).text()
|
||||
const content = await Bun.file(path.join(app.path.root, filepath)).text()
|
||||
const lines = content.split("\n").length
|
||||
changedFiles.push({
|
||||
file: filepath,
|
||||
@@ -72,11 +62,7 @@ export namespace File {
|
||||
}
|
||||
|
||||
// Get deleted files
|
||||
const deletedOutput = await $`git diff --name-only --diff-filter=D HEAD`
|
||||
.cwd(app.path.cwd)
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.text()
|
||||
const deletedOutput = await $`git diff --name-only --diff-filter=D HEAD`.cwd(app.path.cwd).quiet().nothrow().text()
|
||||
|
||||
if (deletedOutput.trim()) {
|
||||
const deletedFiles = deletedOutput.trim().split("\n")
|
||||
@@ -112,11 +98,7 @@ export namespace File {
|
||||
filepath: rel,
|
||||
})
|
||||
if (diff !== "unmodified") {
|
||||
const original = await $`git show HEAD:${rel}`
|
||||
.cwd(app.path.root)
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.text()
|
||||
const original = await $`git show HEAD:${rel}`.cwd(app.path.root).quiet().nothrow().text()
|
||||
const patch = createPatch(file, original, content, "old", "new", {
|
||||
context: Infinity,
|
||||
})
|
||||
|
||||
@@ -122,15 +122,11 @@ export namespace Ripgrep {
|
||||
const state = lazy(async () => {
|
||||
let filepath = Bun.which("rg")
|
||||
if (filepath) return { filepath }
|
||||
filepath = path.join(
|
||||
Global.Path.bin,
|
||||
"rg" + (process.platform === "win32" ? ".exe" : ""),
|
||||
)
|
||||
filepath = path.join(Global.Path.bin, "rg" + (process.platform === "win32" ? ".exe" : ""))
|
||||
|
||||
const file = Bun.file(filepath)
|
||||
if (!(await file.exists())) {
|
||||
const platformKey =
|
||||
`${process.arch}-${process.platform}` as keyof typeof PLATFORM
|
||||
const platformKey = `${process.arch}-${process.platform}` as keyof typeof PLATFORM
|
||||
const config = PLATFORM[platformKey]
|
||||
if (!config) throw new UnsupportedPlatformError({ platform: platformKey })
|
||||
|
||||
@@ -139,8 +135,7 @@ export namespace Ripgrep {
|
||||
const url = `https://github.com/BurntSushi/ripgrep/releases/download/${version}/${filename}`
|
||||
|
||||
const response = await fetch(url)
|
||||
if (!response.ok)
|
||||
throw new DownloadFailedError({ url, status: response.status })
|
||||
if (!response.ok) throw new DownloadFailedError({ url, status: response.status })
|
||||
|
||||
const buffer = await response.arrayBuffer()
|
||||
const archivePath = path.join(Global.Path.bin, filename)
|
||||
@@ -164,14 +159,11 @@ export namespace Ripgrep {
|
||||
})
|
||||
}
|
||||
if (config.extension === "zip") {
|
||||
const proc = Bun.spawn(
|
||||
["unzip", "-j", archivePath, "*/rg.exe", "-d", Global.Path.bin],
|
||||
{
|
||||
cwd: Global.Path.bin,
|
||||
stderr: "pipe",
|
||||
stdout: "ignore",
|
||||
},
|
||||
)
|
||||
const proc = Bun.spawn(["unzip", "-j", archivePath, "*/rg.exe", "-d", Global.Path.bin], {
|
||||
cwd: Global.Path.bin,
|
||||
stderr: "pipe",
|
||||
stdout: "ignore",
|
||||
})
|
||||
await proc.exited
|
||||
if (proc.exitCode !== 0)
|
||||
throw new ExtractionFailedError({
|
||||
@@ -193,17 +185,16 @@ export namespace Ripgrep {
|
||||
return filepath
|
||||
}
|
||||
|
||||
export async function files(input: {
|
||||
cwd: string
|
||||
query?: string
|
||||
glob?: string
|
||||
limit?: number
|
||||
}) {
|
||||
const commands = [
|
||||
`${await filepath()} --files --hidden --glob='!.git/*' ${input.glob ? `--glob='${input.glob}'` : ``}`,
|
||||
]
|
||||
if (input.query)
|
||||
commands.push(`${await Fzf.filepath()} --filter=${input.query}`)
|
||||
export async function files(input: { cwd: string; query?: string; glob?: string[]; limit?: number }) {
|
||||
const commands = [`${$.escape(await filepath())} --files --follow --hidden --glob='!.git/*'`]
|
||||
|
||||
if (input.glob) {
|
||||
for (const g of input.glob) {
|
||||
commands[0] += ` --glob='${g}'`
|
||||
}
|
||||
}
|
||||
|
||||
if (input.query) commands.push(`${await Fzf.filepath()} --filter=${input.query}`)
|
||||
if (input.limit) commands.push(`head -n ${input.limit}`)
|
||||
const joined = commands.join(" | ")
|
||||
const result = await $`${{ raw: joined }}`.cwd(input.cwd).nothrow().text()
|
||||
@@ -310,18 +301,8 @@ export namespace Ripgrep {
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
export async function search(input: {
|
||||
cwd: string
|
||||
pattern: string
|
||||
glob?: string[]
|
||||
limit?: number
|
||||
}) {
|
||||
const args = [
|
||||
`${await filepath()}`,
|
||||
"--json",
|
||||
"--hidden",
|
||||
"--glob='!.git/*'",
|
||||
]
|
||||
export async function search(input: { cwd: string; pattern: string; glob?: string[]; limit?: number }) {
|
||||
const args = [`${await filepath()}`, "--json", "--hidden", "--glob='!.git/*'"]
|
||||
|
||||
if (input.glob) {
|
||||
for (const g of input.glob) {
|
||||
|
||||
@@ -27,10 +27,7 @@ export namespace FileTime {
|
||||
|
||||
export async function assert(sessionID: string, filepath: string) {
|
||||
const time = get(sessionID, filepath)
|
||||
if (!time)
|
||||
throw new Error(
|
||||
`You must read the file ${filepath} before overwriting it. Use the Read tool first`,
|
||||
)
|
||||
if (!time) throw new Error(`You must read the file ${filepath} before overwriting it. Use the Read tool first`)
|
||||
const stats = await Bun.file(filepath).stat()
|
||||
if (stats.mtime.getTime() > time.getTime()) {
|
||||
throw new Error(
|
||||
|
||||
@@ -21,23 +21,20 @@ export namespace FileWatcher {
|
||||
"file.watcher",
|
||||
() => {
|
||||
const app = App.use()
|
||||
if (!app.info.git) return {}
|
||||
try {
|
||||
const watcher = fs.watch(
|
||||
app.info.path.cwd,
|
||||
{ recursive: true },
|
||||
(event, file) => {
|
||||
log.info("change", { file, event })
|
||||
if (!file) return
|
||||
// for some reason async local storage is lost here
|
||||
// https://github.com/oven-sh/bun/issues/20754
|
||||
App.provideExisting(app, async () => {
|
||||
Bus.publish(Event.Updated, {
|
||||
file,
|
||||
event,
|
||||
})
|
||||
const watcher = fs.watch(app.info.path.cwd, { recursive: true }, (event, file) => {
|
||||
log.info("change", { file, event })
|
||||
if (!file) return
|
||||
// for some reason async local storage is lost here
|
||||
// https://github.com/oven-sh/bun/issues/20754
|
||||
App.provideExisting(app, async () => {
|
||||
Bus.publish(Event.Updated, {
|
||||
file,
|
||||
event,
|
||||
})
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
return { watcher }
|
||||
} catch {
|
||||
return {}
|
||||
|
||||
@@ -94,21 +94,7 @@ export const zig: Info = {
|
||||
export const clang: Info = {
|
||||
name: "clang-format",
|
||||
command: ["clang-format", "-i", "$FILE"],
|
||||
extensions: [
|
||||
".c",
|
||||
".cc",
|
||||
".cpp",
|
||||
".cxx",
|
||||
".c++",
|
||||
".h",
|
||||
".hh",
|
||||
".hpp",
|
||||
".hxx",
|
||||
".h++",
|
||||
".ino",
|
||||
".C",
|
||||
".H",
|
||||
],
|
||||
extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"],
|
||||
async enabled() {
|
||||
return Bun.which("clang-format") !== null
|
||||
},
|
||||
|
||||
@@ -26,11 +26,7 @@ export namespace Identifier {
|
||||
return generateID(prefix, true, given)
|
||||
}
|
||||
|
||||
function generateID(
|
||||
prefix: keyof typeof prefixes,
|
||||
descending: boolean,
|
||||
given?: string,
|
||||
): string {
|
||||
function generateID(prefix: keyof typeof prefixes, descending: boolean, given?: string): string {
|
||||
if (!given) {
|
||||
return generateNewID(prefix, descending)
|
||||
}
|
||||
@@ -42,8 +38,7 @@ export namespace Identifier {
|
||||
}
|
||||
|
||||
function randomBase62(length: number): string {
|
||||
const chars =
|
||||
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
let result = ""
|
||||
const bytes = randomBytes(length)
|
||||
for (let i = 0; i < length; i++) {
|
||||
@@ -52,10 +47,7 @@ export namespace Identifier {
|
||||
return result
|
||||
}
|
||||
|
||||
function generateNewID(
|
||||
prefix: keyof typeof prefixes,
|
||||
descending: boolean,
|
||||
): string {
|
||||
function generateNewID(prefix: keyof typeof prefixes, descending: boolean): string {
|
||||
const currentTimestamp = Date.now()
|
||||
|
||||
if (currentTimestamp !== lastTimestamp) {
|
||||
@@ -73,11 +65,6 @@ export namespace Identifier {
|
||||
timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff))
|
||||
}
|
||||
|
||||
return (
|
||||
prefixes[prefix] +
|
||||
"_" +
|
||||
timeBytes.toString("hex") +
|
||||
randomBase62(LENGTH - 12)
|
||||
)
|
||||
return prefixes[prefix] + "_" + timeBytes.toString("hex") + randomBase62(LENGTH - 12)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,10 +55,7 @@ const cli = yargs(hideBin(process.argv))
|
||||
.command(ServeCommand)
|
||||
.command(ModelsCommand)
|
||||
.fail((msg) => {
|
||||
if (
|
||||
msg.startsWith("Unknown argument") ||
|
||||
msg.startsWith("Not enough non-option arguments")
|
||||
) {
|
||||
if (msg.startsWith("Unknown argument") || msg.startsWith("Not enough non-option arguments")) {
|
||||
cli.showHelp("log")
|
||||
}
|
||||
})
|
||||
@@ -97,10 +94,7 @@ try {
|
||||
Log.Default.error("fatal", data)
|
||||
const formatted = FormatError(e)
|
||||
if (formatted) UI.error(formatted)
|
||||
if (formatted === undefined)
|
||||
UI.error(
|
||||
"Unexpected error, check log file at " + Log.file() + " for more details",
|
||||
)
|
||||
if (formatted === undefined) UI.error("Unexpected error, check log file at " + Log.file() + " for more details")
|
||||
process.exitCode = 1
|
||||
}
|
||||
|
||||
|
||||
@@ -135,8 +135,7 @@ export namespace Installation {
|
||||
})
|
||||
}
|
||||
|
||||
export const VERSION =
|
||||
typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "dev"
|
||||
export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "dev"
|
||||
|
||||
export async function latest() {
|
||||
return fetch("https://api.github.com/repos/sst/opencode/releases/latest")
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import path from "path"
|
||||
import {
|
||||
createMessageConnection,
|
||||
StreamMessageReader,
|
||||
StreamMessageWriter,
|
||||
} from "vscode-jsonrpc/node"
|
||||
import { createMessageConnection, StreamMessageReader, StreamMessageWriter } from "vscode-jsonrpc/node"
|
||||
import type { Diagnostic as VSCodeDiagnostic } from "vscode-languageserver-types"
|
||||
import { App } from "../app/app"
|
||||
import { Log } from "../util/log"
|
||||
@@ -38,46 +34,54 @@ export namespace LSPClient {
|
||||
),
|
||||
}
|
||||
|
||||
export async function create(serverID: string, server: LSPServer.Handle) {
|
||||
export async function create(input: { serverID: string; server: LSPServer.Handle; root: string }) {
|
||||
const app = App.info()
|
||||
log.info("starting client", { id: serverID })
|
||||
const l = log.clone().tag("serverID", input.serverID)
|
||||
l.info("starting client")
|
||||
|
||||
const connection = createMessageConnection(
|
||||
new StreamMessageReader(server.process.stdout),
|
||||
new StreamMessageWriter(server.process.stdin),
|
||||
new StreamMessageReader(input.server.process.stdout),
|
||||
new StreamMessageWriter(input.server.process.stdin),
|
||||
)
|
||||
|
||||
const diagnostics = new Map<string, Diagnostic[]>()
|
||||
connection.onNotification("textDocument/publishDiagnostics", (params) => {
|
||||
const path = new URL(params.uri).pathname
|
||||
log.info("textDocument/publishDiagnostics", {
|
||||
l.info("textDocument/publishDiagnostics", {
|
||||
path,
|
||||
})
|
||||
const exists = diagnostics.has(path)
|
||||
diagnostics.set(path, params.diagnostics)
|
||||
if (!exists && serverID === "typescript") return
|
||||
Bus.publish(Event.Diagnostics, { path, serverID })
|
||||
if (!exists && input.serverID === "typescript") return
|
||||
Bus.publish(Event.Diagnostics, { path, serverID: input.serverID })
|
||||
})
|
||||
connection.onRequest("window/workDoneProgress/create", (params) => {
|
||||
l.info("window/workDoneProgress/create", params)
|
||||
return null
|
||||
})
|
||||
connection.onRequest("workspace/configuration", async () => {
|
||||
return [{}]
|
||||
})
|
||||
connection.listen()
|
||||
|
||||
log.info("sending initialize", { id: serverID })
|
||||
l.info("sending initialize")
|
||||
await withTimeout(
|
||||
connection.sendRequest("initialize", {
|
||||
rootUri: "file://" + app.path.cwd,
|
||||
processId: server.process.pid,
|
||||
rootUri: "file://" + input.root,
|
||||
processId: input.server.process.pid,
|
||||
workspaceFolders: [
|
||||
{
|
||||
name: "workspace",
|
||||
uri: "file://" + app.path.cwd,
|
||||
uri: "file://" + input.root,
|
||||
},
|
||||
],
|
||||
initializationOptions: {
|
||||
...server.initialization,
|
||||
...input.server.initialization,
|
||||
},
|
||||
capabilities: {
|
||||
window: {
|
||||
workDoneProgress: true,
|
||||
},
|
||||
workspace: {
|
||||
configuration: true,
|
||||
},
|
||||
@@ -94,9 +98,9 @@ export namespace LSPClient {
|
||||
}),
|
||||
5_000,
|
||||
).catch((err) => {
|
||||
log.error("initialize error", { error: err })
|
||||
l.error("initialize error", { error: err })
|
||||
throw new InitializeError(
|
||||
{ serverID },
|
||||
{ serverID: input.serverID },
|
||||
{
|
||||
cause: err,
|
||||
},
|
||||
@@ -104,26 +108,22 @@ export namespace LSPClient {
|
||||
})
|
||||
|
||||
await connection.sendNotification("initialized", {})
|
||||
log.info("initialized", {
|
||||
serverID,
|
||||
})
|
||||
|
||||
const files: {
|
||||
[path: string]: number
|
||||
} = {}
|
||||
|
||||
const result = {
|
||||
root: input.root,
|
||||
get serverID() {
|
||||
return serverID
|
||||
return input.serverID
|
||||
},
|
||||
get connection() {
|
||||
return connection
|
||||
},
|
||||
notify: {
|
||||
async open(input: { path: string }) {
|
||||
input.path = path.isAbsolute(input.path)
|
||||
? input.path
|
||||
: path.resolve(app.path.cwd, input.path)
|
||||
input.path = path.isAbsolute(input.path) ? input.path : path.resolve(app.path.cwd, input.path)
|
||||
const file = Bun.file(input.path)
|
||||
const text = await file.text()
|
||||
const version = files[input.path]
|
||||
@@ -155,18 +155,13 @@ export namespace LSPClient {
|
||||
return diagnostics
|
||||
},
|
||||
async waitForDiagnostics(input: { path: string }) {
|
||||
input.path = path.isAbsolute(input.path)
|
||||
? input.path
|
||||
: path.resolve(app.path.cwd, input.path)
|
||||
input.path = path.isAbsolute(input.path) ? input.path : path.resolve(app.path.cwd, input.path)
|
||||
log.info("waiting for diagnostics", input)
|
||||
let unsub: () => void
|
||||
return await withTimeout(
|
||||
new Promise<void>((resolve) => {
|
||||
unsub = Bus.subscribe(Event.Diagnostics, (event) => {
|
||||
if (
|
||||
event.properties.path === input.path &&
|
||||
event.properties.serverID === result.serverID
|
||||
) {
|
||||
if (event.properties.path === input.path && event.properties.serverID === result.serverID) {
|
||||
log.info("got diagnostics", input)
|
||||
unsub?.()
|
||||
resolve()
|
||||
@@ -181,13 +176,19 @@ export namespace LSPClient {
|
||||
})
|
||||
},
|
||||
async shutdown() {
|
||||
log.info("shutting down", { serverID })
|
||||
l.info("shutting down")
|
||||
connection.end()
|
||||
connection.dispose()
|
||||
log.info("shutdown", { serverID })
|
||||
input.server.process.kill()
|
||||
l.info("shutdown")
|
||||
},
|
||||
}
|
||||
|
||||
if (input.server.onInitialized) {
|
||||
await input.server.onInitialized(result)
|
||||
}
|
||||
l.info("initialized")
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ import { Log } from "../util/log"
|
||||
import { LSPClient } from "./client"
|
||||
import path from "path"
|
||||
import { LSPServer } from "./server"
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
import { z } from "zod"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
|
||||
export namespace LSP {
|
||||
const log = Log.create({ service: "lsp" })
|
||||
@@ -36,31 +36,39 @@ export namespace LSP {
|
||||
"lsp",
|
||||
async (app) => {
|
||||
log.info("initializing")
|
||||
const clients = new Map<string, LSPClient.Info>()
|
||||
const clients: LSPClient.Info[] = []
|
||||
|
||||
for (const server of Object.values(LSPServer)) {
|
||||
for (const extension of server.extensions) {
|
||||
const [file] = await Ripgrep.files({
|
||||
cwd: app.path.cwd,
|
||||
glob: "*" + extension,
|
||||
const roots = await server.roots(app)
|
||||
|
||||
for (const root of roots) {
|
||||
if (!Filesystem.overlaps(app.path.cwd, root)) continue
|
||||
log.info("", {
|
||||
root,
|
||||
serverID: server.id,
|
||||
})
|
||||
if (!file) continue
|
||||
const handle = await server.spawn(App.info())
|
||||
const handle = await server.spawn(App.info(), root)
|
||||
if (!handle) break
|
||||
const client = await LSPClient.create(server.id, handle).catch(
|
||||
(err) => log.error("", { error: err }),
|
||||
)
|
||||
const client = await LSPClient.create({
|
||||
serverID: server.id,
|
||||
server: handle,
|
||||
root,
|
||||
}).catch((err) => {
|
||||
handle.process.kill()
|
||||
log.error("", { error: err })
|
||||
})
|
||||
if (!client) break
|
||||
clients.set(server.id, client)
|
||||
break
|
||||
clients.push(client)
|
||||
}
|
||||
}
|
||||
|
||||
log.info("initialized")
|
||||
return {
|
||||
clients,
|
||||
}
|
||||
},
|
||||
async (state) => {
|
||||
for (const client of state.clients.values()) {
|
||||
for (const client of state.clients) {
|
||||
await client.shutdown()
|
||||
}
|
||||
},
|
||||
@@ -77,9 +85,7 @@ export namespace LSP {
|
||||
.map((x) => x.id)
|
||||
await run(async (client) => {
|
||||
if (!matches.includes(client.serverID)) return
|
||||
const wait = waitForDiagnostics
|
||||
? client.waitForDiagnostics({ path: input })
|
||||
: Promise.resolve()
|
||||
const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: input }) : Promise.resolve()
|
||||
await client.notify.open({ path: input })
|
||||
return wait
|
||||
})
|
||||
@@ -97,11 +103,7 @@ export namespace LSP {
|
||||
return results
|
||||
}
|
||||
|
||||
export async function hover(input: {
|
||||
file: string
|
||||
line: number
|
||||
character: number
|
||||
}) {
|
||||
export async function hover(input: { file: string; line: number; character: number }) {
|
||||
return run((client) => {
|
||||
return client.connection.sendRequest("textDocument/hover", {
|
||||
textDocument: {
|
||||
@@ -117,16 +119,17 @@ export namespace LSP {
|
||||
|
||||
export async function workspaceSymbol(query: string) {
|
||||
return run((client) =>
|
||||
client.connection.sendRequest("workspace/symbol", {
|
||||
query,
|
||||
}),
|
||||
client.connection
|
||||
.sendRequest("workspace/symbol", {
|
||||
query,
|
||||
})
|
||||
.then((result: any) => result.slice(0, 10))
|
||||
.catch(() => []),
|
||||
).then((result) => result.flat() as LSP.Symbol[])
|
||||
}
|
||||
|
||||
async function run<T>(
|
||||
input: (client: LSPClient.Info) => Promise<T>,
|
||||
): Promise<T[]> {
|
||||
const clients = await state().then((x) => [...x.clients.values()])
|
||||
async function run<T>(input: (client: LSPClient.Info) => Promise<T>): Promise<T[]> {
|
||||
const clients = await state().then((x) => x.clients)
|
||||
const tasks = clients.map((x) => input(x))
|
||||
return Promise.all(tasks)
|
||||
}
|
||||
|
||||
@@ -94,4 +94,6 @@ export const LANGUAGE_EXTENSIONS: Record<string, string> = {
|
||||
".yml": "yaml",
|
||||
".mjs": "javascript",
|
||||
".cjs": "javascript",
|
||||
".zig": "zig",
|
||||
".zon": "zig",
|
||||
} as const
|
||||
|
||||
@@ -6,6 +6,10 @@ import { Log } from "../util/log"
|
||||
import { BunProc } from "../bun"
|
||||
import { $ } from "bun"
|
||||
import fs from "fs/promises"
|
||||
import { unique } from "remeda"
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
import type { LSPClient } from "./client"
|
||||
import { withTimeout } from "../util/timeout"
|
||||
|
||||
export namespace LSPServer {
|
||||
const log = Log.create({ service: "lsp.server" })
|
||||
@@ -13,33 +17,45 @@ export namespace LSPServer {
|
||||
export interface Handle {
|
||||
process: ChildProcessWithoutNullStreams
|
||||
initialization?: Record<string, any>
|
||||
onInitialized?: (lsp: LSPClient.Info) => Promise<void>
|
||||
}
|
||||
|
||||
type RootsFunction = (app: App.Info) => Promise<string[]>
|
||||
|
||||
const SimpleRoots = (patterns: string[]): RootsFunction => {
|
||||
return async (app) => {
|
||||
const glob = `**/*/{${patterns.join(",")}}`
|
||||
const files = await Ripgrep.files({
|
||||
glob: [glob],
|
||||
cwd: app.path.root,
|
||||
})
|
||||
const dirs = files.map((file) => path.dirname(file))
|
||||
return unique(dirs).map((dir) => path.join(app.path.root, dir))
|
||||
}
|
||||
}
|
||||
|
||||
export interface Info {
|
||||
id: string
|
||||
extensions: string[]
|
||||
spawn(app: App.Info): Promise<Handle | undefined>
|
||||
global?: boolean
|
||||
roots: (app: App.Info) => Promise<string[]>
|
||||
spawn(app: App.Info, root: string): Promise<Handle | undefined>
|
||||
}
|
||||
|
||||
export const Typescript: Info = {
|
||||
id: "typescript",
|
||||
roots: SimpleRoots(["tsconfig.json", "jsconfig.json", "package.json"]),
|
||||
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
|
||||
async spawn(app) {
|
||||
const tsserver = await Bun.resolve(
|
||||
"typescript/lib/tsserver.js",
|
||||
app.path.cwd,
|
||||
).catch(() => {})
|
||||
async spawn(app, root) {
|
||||
const tsserver = await Bun.resolve("typescript/lib/tsserver.js", app.path.cwd).catch(() => {})
|
||||
if (!tsserver) return
|
||||
const proc = spawn(
|
||||
BunProc.which(),
|
||||
["x", "typescript-language-server", "--stdio"],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
const proc = spawn(BunProc.which(), ["x", "typescript-language-server", "--stdio"], {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
)
|
||||
})
|
||||
return {
|
||||
process: proc,
|
||||
initialization: {
|
||||
@@ -47,14 +63,32 @@ export namespace LSPServer {
|
||||
path: tsserver,
|
||||
},
|
||||
},
|
||||
// tsserver sucks and won't start processing codebase until you open a file
|
||||
onInitialized: async (lsp) => {
|
||||
const [hint] = await Ripgrep.files({
|
||||
cwd: lsp.root,
|
||||
glob: ["*.ts", "*.tsx", "*.js", "*.jsx", "*.mjs", "*.cjs", "*.mts", "*.cts"],
|
||||
limit: 1,
|
||||
})
|
||||
const wait = new Promise<void>(async (resolve) => {
|
||||
const notif = lsp.connection.onNotification("$/progress", (params) => {
|
||||
if (params.value.kind !== "end") return
|
||||
notif.dispose()
|
||||
resolve()
|
||||
})
|
||||
await lsp.notify.open({ path: path.join(lsp.root, hint) })
|
||||
})
|
||||
await withTimeout(wait, 5_000)
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const Gopls: Info = {
|
||||
id: "golang",
|
||||
roots: SimpleRoots(["go.mod", "go.sum"]),
|
||||
extensions: [".go"],
|
||||
async spawn() {
|
||||
async spawn(_, root) {
|
||||
let bin = Bun.which("gopls", {
|
||||
PATH: process.env["PATH"] + ":" + Global.Path.bin,
|
||||
})
|
||||
@@ -73,24 +107,24 @@ export namespace LSPServer {
|
||||
log.error("Failed to install gopls")
|
||||
return
|
||||
}
|
||||
bin = path.join(
|
||||
Global.Path.bin,
|
||||
"gopls" + (process.platform === "win32" ? ".exe" : ""),
|
||||
)
|
||||
bin = path.join(Global.Path.bin, "gopls" + (process.platform === "win32" ? ".exe" : ""))
|
||||
log.info(`installed gopls`, {
|
||||
bin,
|
||||
})
|
||||
}
|
||||
return {
|
||||
process: spawn(bin!),
|
||||
process: spawn(bin!, {
|
||||
cwd: root,
|
||||
}),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const RubyLsp: Info = {
|
||||
id: "ruby-lsp",
|
||||
roots: SimpleRoots(["Gemfile"]),
|
||||
extensions: [".rb", ".rake", ".gemspec", ".ru"],
|
||||
async spawn() {
|
||||
async spawn(_, root) {
|
||||
let bin = Bun.which("ruby-lsp", {
|
||||
PATH: process.env["PATH"] + ":" + Global.Path.bin,
|
||||
})
|
||||
@@ -113,16 +147,15 @@ export namespace LSPServer {
|
||||
log.error("Failed to install ruby-lsp")
|
||||
return
|
||||
}
|
||||
bin = path.join(
|
||||
Global.Path.bin,
|
||||
"ruby-lsp" + (process.platform === "win32" ? ".exe" : ""),
|
||||
)
|
||||
bin = path.join(Global.Path.bin, "ruby-lsp" + (process.platform === "win32" ? ".exe" : ""))
|
||||
log.info(`installed ruby-lsp`, {
|
||||
bin,
|
||||
})
|
||||
}
|
||||
return {
|
||||
process: spawn(bin!, ["--stdio"]),
|
||||
process: spawn(bin!, ["--stdio"], {
|
||||
cwd: root,
|
||||
}),
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -130,17 +163,22 @@ export namespace LSPServer {
|
||||
export const Pyright: Info = {
|
||||
id: "pyright",
|
||||
extensions: [".py", ".pyi"],
|
||||
async spawn() {
|
||||
const proc = spawn(
|
||||
BunProc.which(),
|
||||
["x", "pyright-langserver", "--stdio"],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
roots: SimpleRoots([
|
||||
"pyproject.toml",
|
||||
"setup.py",
|
||||
"setup.cfg",
|
||||
"requirements.txt",
|
||||
"Pipfile",
|
||||
"pyrightconfig.json",
|
||||
]),
|
||||
async spawn(_, root) {
|
||||
const proc = spawn(BunProc.which(), ["x", "pyright-langserver", "--stdio"], {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
)
|
||||
})
|
||||
return {
|
||||
process: proc,
|
||||
}
|
||||
@@ -150,7 +188,8 @@ export namespace LSPServer {
|
||||
export const ElixirLS: Info = {
|
||||
id: "elixir-ls",
|
||||
extensions: [".ex", ".exs"],
|
||||
async spawn() {
|
||||
roots: SimpleRoots(["mix.exs", "mix.lock"]),
|
||||
async spawn(_, root) {
|
||||
let binary = Bun.which("elixir-ls")
|
||||
if (!binary) {
|
||||
const elixirLsPath = path.join(Global.Path.bin, "elixir-ls")
|
||||
@@ -158,9 +197,7 @@ export namespace LSPServer {
|
||||
Global.Path.bin,
|
||||
"elixir-ls-master",
|
||||
"release",
|
||||
process.platform === "win32"
|
||||
? "language_server.bar"
|
||||
: "language_server.sh",
|
||||
process.platform === "win32" ? "language_server.bar" : "language_server.sh",
|
||||
)
|
||||
|
||||
if (!(await Bun.file(binary).exists())) {
|
||||
@@ -172,9 +209,7 @@ export namespace LSPServer {
|
||||
|
||||
log.info("downloading elixir-ls from GitHub releases")
|
||||
|
||||
const response = await fetch(
|
||||
"https://github.com/elixir-lsp/elixir-ls/archive/refs/heads/master.zip",
|
||||
)
|
||||
const response = await fetch("https://github.com/elixir-lsp/elixir-ls/archive/refs/heads/master.zip")
|
||||
if (!response.ok) return
|
||||
const zipPath = path.join(Global.Path.bin, "elixir-ls.zip")
|
||||
await Bun.file(zipPath).write(response)
|
||||
@@ -198,7 +233,9 @@ export namespace LSPServer {
|
||||
}
|
||||
|
||||
return {
|
||||
process: spawn(binary),
|
||||
process: spawn(binary, {
|
||||
cwd: root,
|
||||
}),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -91,8 +91,7 @@ export namespace Provider {
|
||||
if (!info || info.type !== "oauth") return
|
||||
if (!info.access || info.expires < Date.now()) {
|
||||
const tokens = await copilot.access(info.refresh)
|
||||
if (!tokens)
|
||||
throw new Error("GitHub Copilot authentication expired")
|
||||
if (!tokens) throw new Error("GitHub Copilot authentication expired")
|
||||
await Auth.set("github-copilot", {
|
||||
type: "oauth",
|
||||
...tokens,
|
||||
@@ -100,25 +99,27 @@ export namespace Provider {
|
||||
info.access = tokens.access
|
||||
}
|
||||
let isAgentCall = false
|
||||
let isVisionRequest = false
|
||||
try {
|
||||
const body =
|
||||
typeof init.body === "string"
|
||||
? JSON.parse(init.body)
|
||||
: init.body
|
||||
const body = typeof init.body === "string" ? JSON.parse(init.body) : init.body
|
||||
if (body?.messages) {
|
||||
isAgentCall = body.messages.some(
|
||||
isAgentCall = body.messages.some((msg: any) => msg.role && ["tool", "assistant"].includes(msg.role))
|
||||
isVisionRequest = body.messages.some(
|
||||
(msg: any) =>
|
||||
msg.role && ["tool", "assistant"].includes(msg.role),
|
||||
Array.isArray(msg.content) && msg.content.some((part: any) => part.type === "image_url"),
|
||||
)
|
||||
}
|
||||
} catch {}
|
||||
const headers = {
|
||||
const headers: Record<string, string> = {
|
||||
...init.headers,
|
||||
...copilot.HEADERS,
|
||||
Authorization: `Bearer ${info.access}`,
|
||||
"Openai-Intent": "conversation-edits",
|
||||
"X-Initiator": isAgentCall ? "agent" : "user",
|
||||
}
|
||||
if (isVisionRequest) {
|
||||
headers["Copilot-Vision-Request"] = "true"
|
||||
}
|
||||
delete headers["x-api-key"]
|
||||
return fetch(input, {
|
||||
...init,
|
||||
@@ -138,14 +139,11 @@ export namespace Provider {
|
||||
}
|
||||
},
|
||||
"amazon-bedrock": async () => {
|
||||
if (!process.env["AWS_PROFILE"] && !process.env["AWS_ACCESS_KEY_ID"])
|
||||
return { autoload: false }
|
||||
if (!process.env["AWS_PROFILE"] && !process.env["AWS_ACCESS_KEY_ID"]) return { autoload: false }
|
||||
|
||||
const region = process.env["AWS_REGION"] ?? "us-east-1"
|
||||
|
||||
const { fromNodeProviderChain } = await import(
|
||||
await BunProc.install("@aws-sdk/credential-providers")
|
||||
)
|
||||
const { fromNodeProviderChain } = await import(await BunProc.install("@aws-sdk/credential-providers"))
|
||||
return {
|
||||
autoload: true,
|
||||
options: {
|
||||
@@ -157,9 +155,7 @@ export namespace Provider {
|
||||
|
||||
switch (regionPrefix) {
|
||||
case "us": {
|
||||
const modelRequiresPrefix = ["claude", "deepseek"].some((m) =>
|
||||
modelID.includes(m),
|
||||
)
|
||||
const modelRequiresPrefix = ["claude", "deepseek"].some((m) => modelID.includes(m))
|
||||
if (modelRequiresPrefix) {
|
||||
modelID = `${regionPrefix}.${modelID}`
|
||||
}
|
||||
@@ -174,25 +170,18 @@ export namespace Provider {
|
||||
"eu-south-1",
|
||||
"eu-south-2",
|
||||
].some((r) => region.includes(r))
|
||||
const modelRequiresPrefix = [
|
||||
"claude",
|
||||
"nova-lite",
|
||||
"nova-micro",
|
||||
"llama3",
|
||||
"pixtral",
|
||||
].some((m) => modelID.includes(m))
|
||||
const modelRequiresPrefix = ["claude", "nova-lite", "nova-micro", "llama3", "pixtral"].some((m) =>
|
||||
modelID.includes(m),
|
||||
)
|
||||
if (regionRequiresPrefix && modelRequiresPrefix) {
|
||||
modelID = `${regionPrefix}.${modelID}`
|
||||
}
|
||||
break
|
||||
}
|
||||
case "ap": {
|
||||
const modelRequiresPrefix = [
|
||||
"claude",
|
||||
"nova-lite",
|
||||
"nova-micro",
|
||||
"nova-pro",
|
||||
].some((m) => modelID.includes(m))
|
||||
const modelRequiresPrefix = ["claude", "nova-lite", "nova-micro", "nova-pro"].some((m) =>
|
||||
modelID.includes(m),
|
||||
)
|
||||
if (modelRequiresPrefix) {
|
||||
regionPrefix = "apac"
|
||||
modelID = `${regionPrefix}.${modelID}`
|
||||
@@ -230,10 +219,7 @@ export namespace Provider {
|
||||
options: Record<string, any>
|
||||
}
|
||||
} = {}
|
||||
const models = new Map<
|
||||
string,
|
||||
{ info: ModelsDev.Model; language: LanguageModel }
|
||||
>()
|
||||
const models = new Map<string, { info: ModelsDev.Model; language: LanguageModel }>()
|
||||
const sdk = new Map<string, SDK>()
|
||||
|
||||
log.info("init")
|
||||
@@ -308,9 +294,7 @@ export namespace Provider {
|
||||
database[providerID] = parsed
|
||||
}
|
||||
|
||||
const disabled = await Config.get().then(
|
||||
(cfg) => new Set(cfg.disabled_providers ?? []),
|
||||
)
|
||||
const disabled = await Config.get().then((cfg) => new Set(cfg.disabled_providers ?? []))
|
||||
// load env
|
||||
for (const [providerID, provider] of Object.entries(database)) {
|
||||
if (disabled.has(providerID)) continue
|
||||
@@ -337,12 +321,7 @@ export namespace Provider {
|
||||
if (disabled.has(providerID)) continue
|
||||
const result = await fn(database[providerID])
|
||||
if (result && (result.autoload || providers[providerID])) {
|
||||
mergeProvider(
|
||||
providerID,
|
||||
result.options ?? {},
|
||||
"custom",
|
||||
result.getModel,
|
||||
)
|
||||
mergeProvider(providerID, result.options ?? {}, "custom", result.getModel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,7 +358,7 @@ export namespace Provider {
|
||||
const existing = s.sdk.get(provider.id)
|
||||
if (existing) return existing
|
||||
const pkg = provider.npm ?? provider.id
|
||||
const mod = await import(await BunProc.install(pkg, "latest"))
|
||||
const mod = await import(await BunProc.install(pkg, "beta"))
|
||||
const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!]
|
||||
const loaded = fn(s.providers[provider.id]?.options)
|
||||
s.sdk.set(provider.id, loaded)
|
||||
@@ -406,9 +385,7 @@ export namespace Provider {
|
||||
const sdk = await getSDK(provider.info)
|
||||
|
||||
try {
|
||||
const language = provider.getModel
|
||||
? await provider.getModel(sdk, modelID)
|
||||
: sdk.languageModel(modelID)
|
||||
const language = provider.getModel ? await provider.getModel(sdk, modelID) : sdk.languageModel(modelID)
|
||||
log.info("found", { providerID, modelID })
|
||||
s.models.set(key, {
|
||||
info,
|
||||
@@ -435,10 +412,7 @@ export namespace Provider {
|
||||
export function sort(models: ModelsDev.Model[]) {
|
||||
return sortBy(
|
||||
models,
|
||||
[
|
||||
(model) => priority.findIndex((filter) => model.id.includes(filter)),
|
||||
"desc",
|
||||
],
|
||||
[(model) => priority.findIndex((filter) => model.id.includes(filter)), "desc"],
|
||||
[(model) => (model.id.includes("latest") ? 0 : 1), "asc"],
|
||||
[(model) => model.id, "desc"],
|
||||
)
|
||||
@@ -449,11 +423,7 @@ export namespace Provider {
|
||||
if (cfg.model) return parseModel(cfg.model)
|
||||
const provider = await list()
|
||||
.then((val) => Object.values(val))
|
||||
.then((x) =>
|
||||
x.find(
|
||||
(p) => !cfg.provider || Object.keys(cfg.provider).includes(p.info.id),
|
||||
),
|
||||
)
|
||||
.then((x) => x.find((p) => !cfg.provider || Object.keys(cfg.provider).includes(p.info.id)))
|
||||
if (!provider) throw new Error("no providers found")
|
||||
const [model] = sort(Object.values(provider.info.models))
|
||||
if (!model) throw new Error("no models found")
|
||||
@@ -536,9 +506,11 @@ export namespace Provider {
|
||||
|
||||
if (schema instanceof z.ZodUnion) {
|
||||
return z.union(
|
||||
schema.options.map((option: z.ZodTypeAny) =>
|
||||
optionalToNullable(option),
|
||||
) as [z.ZodTypeAny, z.ZodTypeAny, ...z.ZodTypeAny[]],
|
||||
schema.options.map((option: z.ZodTypeAny) => optionalToNullable(option)) as [
|
||||
z.ZodTypeAny,
|
||||
z.ZodTypeAny,
|
||||
...z.ZodTypeAny[],
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
import type { LanguageModelV1Prompt } from "ai"
|
||||
import type { ModelMessage } from "ai"
|
||||
import { unique } from "remeda"
|
||||
|
||||
export namespace ProviderTransform {
|
||||
export function message(
|
||||
msgs: LanguageModelV1Prompt,
|
||||
providerID: string,
|
||||
modelID: string,
|
||||
) {
|
||||
export function message(msgs: ModelMessage[], providerID: string, modelID: string) {
|
||||
if (providerID === "anthropic" || modelID.includes("anthropic")) {
|
||||
const system = msgs.filter((msg) => msg.role === "system").slice(0, 2)
|
||||
const final = msgs.filter((msg) => msg.role !== "system").slice(-2)
|
||||
|
||||
for (const msg of unique([...system, ...final])) {
|
||||
msg.providerMetadata = {
|
||||
...msg.providerMetadata,
|
||||
msg.providerOptions = {
|
||||
...msg.providerOptions,
|
||||
anthropic: {
|
||||
cacheControl: { type: "ephemeral" },
|
||||
},
|
||||
@@ -28,8 +24,8 @@ export namespace ProviderTransform {
|
||||
const final = msgs.filter((msg) => msg.role !== "system").slice(-2)
|
||||
|
||||
for (const msg of unique([...system, ...final])) {
|
||||
msg.providerMetadata = {
|
||||
...msg.providerMetadata,
|
||||
msg.providerOptions = {
|
||||
...msg.providerOptions,
|
||||
bedrock: {
|
||||
cachePoint: { type: "ephemeral" },
|
||||
},
|
||||
|
||||
@@ -6,7 +6,6 @@ import { streamSSE } from "hono/streaming"
|
||||
import { Session } from "../session"
|
||||
import { resolver, validator as zValidator } from "hono-openapi/zod"
|
||||
import { z } from "zod"
|
||||
import { Message } from "../session/message"
|
||||
import { Provider } from "../provider/provider"
|
||||
import { App } from "../app/app"
|
||||
import { mapValues } from "remeda"
|
||||
@@ -16,6 +15,7 @@ import { Ripgrep } from "../file/ripgrep"
|
||||
import { Config } from "../config/config"
|
||||
import { File } from "../file"
|
||||
import { LSP } from "../lsp"
|
||||
import { MessageV2 } from "../session/message-v2"
|
||||
|
||||
const ERRORS = {
|
||||
400: {
|
||||
@@ -51,12 +51,9 @@ export namespace Server {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
return c.json(
|
||||
new NamedError.Unknown({ message: err.toString() }).toObject(),
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
)
|
||||
return c.json(new NamedError.Unknown({ message: err.toString() }).toObject(), {
|
||||
status: 400,
|
||||
})
|
||||
})
|
||||
.use(async (c, next) => {
|
||||
log.info("request", {
|
||||
@@ -407,7 +404,7 @@ export namespace Server {
|
||||
description: "List of messages",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Message.Info.array()),
|
||||
schema: resolver(MessageV2.Info.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -433,7 +430,7 @@ export namespace Server {
|
||||
description: "Created message",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Message.Info),
|
||||
schema: resolver(MessageV2.Assistant),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -450,7 +447,7 @@ export namespace Server {
|
||||
z.object({
|
||||
providerID: z.string(),
|
||||
modelID: z.string(),
|
||||
parts: Message.MessagePart.array(),
|
||||
parts: MessageV2.UserPart.array(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
@@ -481,15 +478,10 @@ export namespace Server {
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const providers = await Provider.list().then((x) =>
|
||||
mapValues(x, (item) => item.info),
|
||||
)
|
||||
const providers = await Provider.list().then((x) => mapValues(x, (item) => item.info))
|
||||
return c.json({
|
||||
providers: Object.values(providers),
|
||||
default: mapValues(
|
||||
providers,
|
||||
(item) => Provider.sort(Object.values(item.models))[0].id,
|
||||
),
|
||||
default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,404 @@
|
||||
import z from "zod"
|
||||
import { Bus } from "../bus"
|
||||
import { Provider } from "../provider/provider"
|
||||
import { NamedError } from "../util/error"
|
||||
import { Message } from "./message"
|
||||
import { convertToModelMessages, type ModelMessage, type UIMessage } from "ai"
|
||||
|
||||
export namespace MessageV2 {
|
||||
export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({}))
|
||||
export const AbortedError = NamedError.create("MessageAbortedError", z.object({}))
|
||||
|
||||
export const ToolStatePending = z
|
||||
.object({
|
||||
status: z.literal("pending"),
|
||||
})
|
||||
.openapi({
|
||||
ref: "ToolStatePending",
|
||||
})
|
||||
|
||||
export type ToolStatePending = z.infer<typeof ToolStatePending>
|
||||
|
||||
export const ToolStateRunning = z
|
||||
.object({
|
||||
status: z.literal("running"),
|
||||
input: z.any(),
|
||||
title: z.string().optional(),
|
||||
metadata: z.record(z.any()).optional(),
|
||||
time: z.object({
|
||||
start: z.number(),
|
||||
}),
|
||||
})
|
||||
.openapi({
|
||||
ref: "ToolStateRunning",
|
||||
})
|
||||
export type ToolStateRunning = z.infer<typeof ToolStateRunning>
|
||||
|
||||
export const ToolStateCompleted = z
|
||||
.object({
|
||||
status: z.literal("completed"),
|
||||
input: z.record(z.any()),
|
||||
output: z.string(),
|
||||
title: z.string(),
|
||||
metadata: z.record(z.any()),
|
||||
time: z.object({
|
||||
start: z.number(),
|
||||
end: z.number(),
|
||||
}),
|
||||
})
|
||||
.openapi({
|
||||
ref: "ToolStateCompleted",
|
||||
})
|
||||
export type ToolStateCompleted = z.infer<typeof ToolStateCompleted>
|
||||
|
||||
export const ToolStateError = z
|
||||
.object({
|
||||
status: z.literal("error"),
|
||||
input: z.record(z.any()),
|
||||
error: z.string(),
|
||||
time: z.object({
|
||||
start: z.number(),
|
||||
end: z.number(),
|
||||
}),
|
||||
})
|
||||
.openapi({
|
||||
ref: "ToolStateError",
|
||||
})
|
||||
export type ToolStateError = z.infer<typeof ToolStateError>
|
||||
|
||||
export const ToolState = z
|
||||
.discriminatedUnion("status", [ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError])
|
||||
.openapi({
|
||||
ref: "ToolState",
|
||||
})
|
||||
|
||||
export const TextPart = z
|
||||
.object({
|
||||
type: z.literal("text"),
|
||||
text: z.string(),
|
||||
})
|
||||
.openapi({
|
||||
ref: "TextPart",
|
||||
})
|
||||
export type TextPart = z.infer<typeof TextPart>
|
||||
|
||||
export const ToolPart = z
|
||||
.object({
|
||||
type: z.literal("tool"),
|
||||
id: z.string(),
|
||||
tool: z.string(),
|
||||
state: ToolState,
|
||||
})
|
||||
.openapi({
|
||||
ref: "ToolPart",
|
||||
})
|
||||
export type ToolPart = z.infer<typeof ToolPart>
|
||||
|
||||
export const FilePart = z
|
||||
.object({
|
||||
type: z.literal("file"),
|
||||
mime: z.string(),
|
||||
filename: z.string().optional(),
|
||||
url: z.string(),
|
||||
})
|
||||
.openapi({
|
||||
ref: "FilePart",
|
||||
})
|
||||
export type FilePart = z.infer<typeof FilePart>
|
||||
|
||||
export const StepStartPart = z
|
||||
.object({
|
||||
type: z.literal("step-start"),
|
||||
})
|
||||
.openapi({
|
||||
ref: "StepStartPart",
|
||||
})
|
||||
export type StepStartPart = z.infer<typeof StepStartPart>
|
||||
|
||||
const Base = z.object({
|
||||
id: z.string(),
|
||||
sessionID: z.string(),
|
||||
})
|
||||
|
||||
export const UserPart = z.discriminatedUnion("type", [TextPart, FilePart]).openapi({
|
||||
ref: "UserMessagePart",
|
||||
})
|
||||
export type UserPart = z.infer<typeof UserPart>
|
||||
|
||||
export const User = Base.extend({
|
||||
role: z.literal("user"),
|
||||
parts: z.array(UserPart),
|
||||
time: z.object({
|
||||
created: z.number(),
|
||||
}),
|
||||
}).openapi({
|
||||
ref: "UserMessage",
|
||||
})
|
||||
export type User = z.infer<typeof User>
|
||||
|
||||
export const AssistantPart = z.discriminatedUnion("type", [TextPart, ToolPart, StepStartPart]).openapi({
|
||||
ref: "AssistantMessagePart",
|
||||
})
|
||||
export type AssistantPart = z.infer<typeof AssistantPart>
|
||||
|
||||
export const Assistant = Base.extend({
|
||||
role: z.literal("assistant"),
|
||||
parts: z.array(AssistantPart),
|
||||
time: z.object({
|
||||
created: z.number(),
|
||||
completed: z.number().optional(),
|
||||
}),
|
||||
error: z
|
||||
.discriminatedUnion("name", [
|
||||
Provider.AuthError.Schema,
|
||||
NamedError.Unknown.Schema,
|
||||
OutputLengthError.Schema,
|
||||
AbortedError.Schema,
|
||||
])
|
||||
.optional(),
|
||||
system: z.string().array(),
|
||||
modelID: z.string(),
|
||||
providerID: z.string(),
|
||||
path: z.object({
|
||||
cwd: z.string(),
|
||||
root: z.string(),
|
||||
}),
|
||||
cost: z.number(),
|
||||
summary: z.boolean().optional(),
|
||||
tokens: z.object({
|
||||
input: z.number(),
|
||||
output: z.number(),
|
||||
reasoning: z.number(),
|
||||
cache: z.object({
|
||||
read: z.number(),
|
||||
write: z.number(),
|
||||
}),
|
||||
}),
|
||||
}).openapi({
|
||||
ref: "AssistantMessage",
|
||||
})
|
||||
export type Assistant = z.infer<typeof Assistant>
|
||||
|
||||
export const Info = z.discriminatedUnion("role", [User, Assistant]).openapi({
|
||||
ref: "Message",
|
||||
})
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
export const Event = {
|
||||
Updated: Bus.event(
|
||||
"message.updated",
|
||||
z.object({
|
||||
info: Info,
|
||||
}),
|
||||
),
|
||||
Removed: Bus.event(
|
||||
"message.removed",
|
||||
z.object({
|
||||
sessionID: z.string(),
|
||||
messageID: z.string(),
|
||||
}),
|
||||
),
|
||||
PartUpdated: Bus.event(
|
||||
"message.part.updated",
|
||||
z.object({
|
||||
part: AssistantPart,
|
||||
sessionID: z.string(),
|
||||
messageID: z.string(),
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
export function fromV1(v1: Message.Info) {
|
||||
if (v1.role === "assistant") {
|
||||
const result: Assistant = {
|
||||
id: v1.id,
|
||||
sessionID: v1.metadata.sessionID,
|
||||
role: "assistant",
|
||||
time: {
|
||||
created: v1.metadata.time.created,
|
||||
completed: v1.metadata.time.completed,
|
||||
},
|
||||
cost: v1.metadata.assistant!.cost,
|
||||
path: v1.metadata.assistant!.path,
|
||||
summary: v1.metadata.assistant!.summary,
|
||||
tokens: v1.metadata.assistant!.tokens,
|
||||
modelID: v1.metadata.assistant!.modelID,
|
||||
providerID: v1.metadata.assistant!.providerID,
|
||||
system: v1.metadata.assistant!.system,
|
||||
error: v1.metadata.error,
|
||||
parts: v1.parts.flatMap((part): AssistantPart[] => {
|
||||
if (part.type === "text") {
|
||||
return [
|
||||
{
|
||||
type: "text",
|
||||
text: part.text,
|
||||
},
|
||||
]
|
||||
}
|
||||
if (part.type === "step-start") {
|
||||
return [
|
||||
{
|
||||
type: "step-start",
|
||||
},
|
||||
]
|
||||
}
|
||||
if (part.type === "tool-invocation") {
|
||||
return [
|
||||
{
|
||||
type: "tool",
|
||||
id: part.toolInvocation.toolCallId,
|
||||
tool: part.toolInvocation.toolName,
|
||||
state: (() => {
|
||||
if (part.toolInvocation.state === "partial-call") {
|
||||
return {
|
||||
status: "pending",
|
||||
}
|
||||
}
|
||||
|
||||
const { title, time, ...metadata } = v1.metadata.tool[part.toolInvocation.toolCallId] ?? {}
|
||||
if (part.toolInvocation.state === "call") {
|
||||
return {
|
||||
status: "running",
|
||||
input: part.toolInvocation.args,
|
||||
time: {
|
||||
start: time?.start,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (part.toolInvocation.state === "result") {
|
||||
return {
|
||||
status: "completed",
|
||||
input: part.toolInvocation.args,
|
||||
output: part.toolInvocation.result,
|
||||
title,
|
||||
time,
|
||||
metadata,
|
||||
}
|
||||
}
|
||||
throw new Error("unknown tool invocation state")
|
||||
})(),
|
||||
},
|
||||
]
|
||||
}
|
||||
return []
|
||||
}),
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
if (v1.role === "user") {
|
||||
const result: User = {
|
||||
id: v1.id,
|
||||
sessionID: v1.metadata.sessionID,
|
||||
role: "user",
|
||||
time: {
|
||||
created: v1.metadata.time.created,
|
||||
},
|
||||
parts: v1.parts.flatMap((part): UserPart[] => {
|
||||
if (part.type === "text") {
|
||||
return [
|
||||
{
|
||||
type: "text",
|
||||
text: part.text,
|
||||
},
|
||||
]
|
||||
}
|
||||
if (part.type === "file") {
|
||||
return [
|
||||
{
|
||||
type: "file",
|
||||
mime: part.mediaType,
|
||||
filename: part.filename,
|
||||
url: part.url,
|
||||
},
|
||||
]
|
||||
}
|
||||
return []
|
||||
}),
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
export function toModelMessage(input: Info[]): ModelMessage[] {
|
||||
const result: UIMessage[] = []
|
||||
|
||||
for (const msg of input) {
|
||||
if (msg.parts.length === 0) continue
|
||||
if (msg.role === "user") {
|
||||
result.push({
|
||||
id: msg.id,
|
||||
role: "user",
|
||||
parts: msg.parts.flatMap((part): UIMessage["parts"] => {
|
||||
if (part.type === "text")
|
||||
return [
|
||||
{
|
||||
type: "text",
|
||||
text: part.text,
|
||||
},
|
||||
]
|
||||
if (part.type === "file")
|
||||
return [
|
||||
{
|
||||
type: "file",
|
||||
url: part.url,
|
||||
mediaType: part.mime,
|
||||
filename: part.filename,
|
||||
},
|
||||
]
|
||||
return []
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
if (msg.role === "assistant") {
|
||||
result.push({
|
||||
id: msg.id,
|
||||
role: "assistant",
|
||||
parts: msg.parts.flatMap((part): UIMessage["parts"] => {
|
||||
if (part.type === "text")
|
||||
return [
|
||||
{
|
||||
type: "text",
|
||||
text: part.text,
|
||||
},
|
||||
]
|
||||
if (part.type === "step-start")
|
||||
return [
|
||||
{
|
||||
type: "step-start",
|
||||
},
|
||||
]
|
||||
if (part.type === "tool") {
|
||||
if (part.state.status === "completed")
|
||||
return [
|
||||
{
|
||||
type: ("tool-" + part.tool) as `tool-${string}`,
|
||||
state: "output-available",
|
||||
toolCallId: part.id,
|
||||
input: part.state.input,
|
||||
output: part.state.output,
|
||||
},
|
||||
]
|
||||
if (part.state.status === "error")
|
||||
return [
|
||||
{
|
||||
type: ("tool-" + part.tool) as `tool-${string}`,
|
||||
state: "output-error",
|
||||
toolCallId: part.id,
|
||||
input: part.state.input,
|
||||
errorText: part.state.error,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
return []
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return convertToModelMessages(result)
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,9 @@
|
||||
import z from "zod"
|
||||
import { Bus } from "../bus"
|
||||
import { Provider } from "../provider/provider"
|
||||
import { NamedError } from "../util/error"
|
||||
|
||||
export namespace Message {
|
||||
export const OutputLengthError = NamedError.create(
|
||||
"MessageOutputLengthError",
|
||||
z.object({}),
|
||||
)
|
||||
export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({}))
|
||||
|
||||
export const ToolCall = z
|
||||
.object({
|
||||
@@ -49,11 +45,9 @@ export namespace Message {
|
||||
})
|
||||
export type ToolResult = z.infer<typeof ToolResult>
|
||||
|
||||
export const ToolInvocation = z
|
||||
.discriminatedUnion("state", [ToolCall, ToolPartialCall, ToolResult])
|
||||
.openapi({
|
||||
ref: "ToolInvocation",
|
||||
})
|
||||
export const ToolInvocation = z.discriminatedUnion("state", [ToolCall, ToolPartialCall, ToolResult]).openapi({
|
||||
ref: "ToolInvocation",
|
||||
})
|
||||
export type ToolInvocation = z.infer<typeof ToolInvocation>
|
||||
|
||||
export const TextPart = z
|
||||
@@ -122,14 +116,7 @@ export namespace Message {
|
||||
export type StepStartPart = z.infer<typeof StepStartPart>
|
||||
|
||||
export const MessagePart = z
|
||||
.discriminatedUnion("type", [
|
||||
TextPart,
|
||||
ReasoningPart,
|
||||
ToolInvocationPart,
|
||||
SourceUrlPart,
|
||||
FilePart,
|
||||
StepStartPart,
|
||||
])
|
||||
.discriminatedUnion("type", [TextPart, ReasoningPart, ToolInvocationPart, SourceUrlPart, FilePart, StepStartPart])
|
||||
.openapi({
|
||||
ref: "MessagePart",
|
||||
})
|
||||
@@ -197,28 +184,4 @@ export namespace Message {
|
||||
ref: "Message",
|
||||
})
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
export const Event = {
|
||||
Updated: Bus.event(
|
||||
"message.updated",
|
||||
z.object({
|
||||
info: Info,
|
||||
}),
|
||||
),
|
||||
Removed: Bus.event(
|
||||
"message.removed",
|
||||
z.object({
|
||||
sessionID: z.string(),
|
||||
messageID: z.string(),
|
||||
}),
|
||||
),
|
||||
PartUpdated: Bus.event(
|
||||
"message.part.updated",
|
||||
z.object({
|
||||
part: MessagePart,
|
||||
sessionID: z.string(),
|
||||
messageID: z.string(),
|
||||
}),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,9 +53,7 @@ export namespace Share {
|
||||
|
||||
export const URL =
|
||||
process.env["OPENCODE_API"] ??
|
||||
(Installation.isSnapshot() || Installation.isDev()
|
||||
? "https://api.dev.opencode.ai"
|
||||
: "https://api.opencode.ai")
|
||||
(Installation.isSnapshot() || Installation.isDev() ? "https://api.dev.opencode.ai" : "https://api.opencode.ai")
|
||||
|
||||
export async function create(sessionID: string) {
|
||||
return fetch(`${URL}/share_create`, {
|
||||
|
||||
@@ -55,9 +55,7 @@ export namespace Snapshot {
|
||||
log.info("restore", { commit })
|
||||
const app = App.info()
|
||||
const git = gitdir(sessionID)
|
||||
await $`git --git-dir=${git} checkout ${commit} --force`
|
||||
.quiet()
|
||||
.cwd(app.path.root)
|
||||
await $`git --git-dir=${git} checkout ${commit} --force`.quiet().cwd(app.path.root)
|
||||
}
|
||||
|
||||
function gitdir(sessionID: string) {
|
||||
|
||||
@@ -4,44 +4,80 @@ import { Bus } from "../bus"
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
import fs from "fs/promises"
|
||||
import { MessageV2 } from "../session/message-v2"
|
||||
|
||||
export namespace Storage {
|
||||
const log = Log.create({ service: "storage" })
|
||||
|
||||
export const Event = {
|
||||
Write: Bus.event(
|
||||
"storage.write",
|
||||
z.object({ key: z.string(), content: z.any() }),
|
||||
),
|
||||
Write: Bus.event("storage.write", z.object({ key: z.string(), content: z.any() })),
|
||||
}
|
||||
|
||||
const state = App.state("storage", () => {
|
||||
type Migration = (dir: string) => Promise<void>
|
||||
|
||||
const MIGRATIONS: Migration[] = [
|
||||
async (dir: string) => {
|
||||
try {
|
||||
const files = new Bun.Glob("session/message/*/*.json").scanSync({
|
||||
cwd: dir,
|
||||
absolute: true,
|
||||
})
|
||||
for (const file of files) {
|
||||
const content = await Bun.file(file).json()
|
||||
if (!content.metadata) continue
|
||||
log.info("migrating to v2 message", { file })
|
||||
try {
|
||||
const result = MessageV2.fromV1(content)
|
||||
await Bun.write(file, JSON.stringify(result, null, 2))
|
||||
} catch (e) {
|
||||
await fs.rename(file, file.replace("storage", "broken"))
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
},
|
||||
]
|
||||
|
||||
const state = App.state("storage", async () => {
|
||||
const app = App.info()
|
||||
const dir = path.join(app.path.data, "storage")
|
||||
log.info("init", { path: dir })
|
||||
const dir = path.normalize(path.join(app.path.data, "storage"))
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
const migration = await Bun.file(path.join(dir, "migration"))
|
||||
.json()
|
||||
.then((x) => parseInt(x))
|
||||
.catch(() => 0)
|
||||
for (let index = migration; index < MIGRATIONS.length; index++) {
|
||||
log.info("running migration", { index })
|
||||
const migration = MIGRATIONS[index]
|
||||
await migration(dir)
|
||||
await Bun.write(path.join(dir, "migration"), (index + 1).toString())
|
||||
}
|
||||
return {
|
||||
dir,
|
||||
}
|
||||
})
|
||||
|
||||
export async function remove(key: string) {
|
||||
const target = path.join(state().dir, key + ".json")
|
||||
const dir = await state().then((x) => x.dir)
|
||||
const target = path.join(dir, key + ".json")
|
||||
await fs.unlink(target).catch(() => {})
|
||||
}
|
||||
|
||||
export async function removeDir(key: string) {
|
||||
const target = path.join(state().dir, key)
|
||||
const dir = await state().then((x) => x.dir)
|
||||
const target = path.join(dir, key)
|
||||
await fs.rm(target, { recursive: true, force: true }).catch(() => {})
|
||||
}
|
||||
|
||||
export async function readJSON<T>(key: string) {
|
||||
return Bun.file(path.join(state().dir, key + ".json")).json() as Promise<T>
|
||||
const dir = await state().then((x) => x.dir)
|
||||
return Bun.file(path.join(dir, key + ".json")).json() as Promise<T>
|
||||
}
|
||||
|
||||
export async function writeJSON<T>(key: string, content: T) {
|
||||
const target = path.join(state().dir, key + ".json")
|
||||
const dir = await state().then((x) => x.dir)
|
||||
const target = path.join(dir, key + ".json")
|
||||
const tmp = target + Date.now() + ".tmp"
|
||||
await Bun.write(tmp, JSON.stringify(content))
|
||||
await Bun.write(tmp, JSON.stringify(content, null, 2))
|
||||
await fs.rename(tmp, target).catch(() => {})
|
||||
await fs.unlink(tmp).catch(() => {})
|
||||
Bus.publish(Event.Write, { key, content })
|
||||
@@ -49,9 +85,10 @@ export namespace Storage {
|
||||
|
||||
const glob = new Bun.Glob("**/*")
|
||||
export async function* list(prefix: string) {
|
||||
const dir = await state().then((x) => x.dir)
|
||||
try {
|
||||
for await (const item of glob.scan({
|
||||
cwd: path.join(state().dir, prefix),
|
||||
cwd: path.join(dir, prefix),
|
||||
onlyFiles: true,
|
||||
})) {
|
||||
const result = path.join(prefix, item.slice(0, -5))
|
||||
|
||||
@@ -12,12 +12,7 @@ export const BashTool = Tool.define({
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
command: z.string().describe("The command to execute"),
|
||||
timeout: z
|
||||
.number()
|
||||
.min(0)
|
||||
.max(MAX_TIMEOUT)
|
||||
.describe("Optional timeout in milliseconds")
|
||||
.optional(),
|
||||
timeout: z.number().min(0).max(MAX_TIMEOUT).describe("Optional timeout in milliseconds").optional(),
|
||||
description: z
|
||||
.string()
|
||||
.describe(
|
||||
@@ -41,21 +36,14 @@ export const BashTool = Tool.define({
|
||||
const stderr = await new Response(process.stderr).text()
|
||||
|
||||
return {
|
||||
title: params.command,
|
||||
metadata: {
|
||||
stderr,
|
||||
stdout,
|
||||
exit: process.exitCode,
|
||||
description: params.description,
|
||||
title: params.command,
|
||||
},
|
||||
output: [
|
||||
`<stdout>`,
|
||||
stdout ?? "",
|
||||
`</stdout>`,
|
||||
`<stderr>`,
|
||||
stderr ?? "",
|
||||
`</stderr>`,
|
||||
].join("\n"),
|
||||
output: [`<stdout>`, stdout ?? "", `</stdout>`, `<stderr>`, stderr ?? "", `</stderr>`].join("\n"),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -20,15 +20,8 @@ export const EditTool = Tool.define({
|
||||
parameters: z.object({
|
||||
filePath: z.string().describe("The absolute path to the file to modify"),
|
||||
oldString: z.string().describe("The text to replace"),
|
||||
newString: z
|
||||
.string()
|
||||
.describe(
|
||||
"The text to replace it with (must be different from old_string)",
|
||||
),
|
||||
replaceAll: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Replace all occurrences of old_string (default false)"),
|
||||
newString: z.string().describe("The text to replace it with (must be different from old_string)"),
|
||||
replaceAll: z.boolean().optional().describe("Replace all occurrences of old_string (default false)"),
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
if (!params.filePath) {
|
||||
@@ -40,9 +33,7 @@ export const EditTool = Tool.define({
|
||||
}
|
||||
|
||||
const app = App.info()
|
||||
const filepath = path.isAbsolute(params.filePath)
|
||||
? params.filePath
|
||||
: path.join(app.path.cwd, params.filePath)
|
||||
const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(app.path.cwd, params.filePath)
|
||||
|
||||
await Permission.ask({
|
||||
id: "edit",
|
||||
@@ -70,17 +61,11 @@ export const EditTool = Tool.define({
|
||||
const file = Bun.file(filepath)
|
||||
const stats = await file.stat().catch(() => {})
|
||||
if (!stats) throw new Error(`File ${filepath} not found`)
|
||||
if (stats.isDirectory())
|
||||
throw new Error(`Path is a directory, not a file: ${filepath}`)
|
||||
if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filepath}`)
|
||||
await FileTime.assert(ctx.sessionID, filepath)
|
||||
contentOld = await file.text()
|
||||
|
||||
contentNew = replace(
|
||||
contentOld,
|
||||
params.oldString,
|
||||
params.newString,
|
||||
params.replaceAll,
|
||||
)
|
||||
contentNew = replace(contentOld, params.oldString, params.newString, params.replaceAll)
|
||||
await file.write(contentNew)
|
||||
await Bus.publish(File.Event.Edited, {
|
||||
file: filepath,
|
||||
@@ -88,9 +73,7 @@ export const EditTool = Tool.define({
|
||||
contentNew = await file.text()
|
||||
})()
|
||||
|
||||
const diff = trimDiff(
|
||||
createTwoFilesPatch(filepath, filepath, contentOld, contentNew),
|
||||
)
|
||||
const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, contentNew))
|
||||
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
@@ -110,17 +93,14 @@ export const EditTool = Tool.define({
|
||||
metadata: {
|
||||
diagnostics,
|
||||
diff,
|
||||
title: `${path.relative(app.path.root, filepath)}`,
|
||||
},
|
||||
title: `${path.relative(app.path.root, filepath)}`,
|
||||
output,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export type Replacer = (
|
||||
content: string,
|
||||
find: string,
|
||||
) => Generator<string, void, unknown>
|
||||
export type Replacer = (content: string, find: string) => Generator<string, void, unknown>
|
||||
|
||||
export const SimpleReplacer: Replacer = function* (_content, find) {
|
||||
yield find
|
||||
@@ -208,10 +188,7 @@ export const BlockAnchorReplacer: Replacer = function* (content, find) {
|
||||
}
|
||||
}
|
||||
|
||||
export const WhitespaceNormalizedReplacer: Replacer = function* (
|
||||
content,
|
||||
find,
|
||||
) {
|
||||
export const WhitespaceNormalizedReplacer: Replacer = function* (content, find) {
|
||||
const normalizeWhitespace = (text: string) => text.replace(/\s+/g, " ").trim()
|
||||
const normalizedFind = normalizeWhitespace(find)
|
||||
|
||||
@@ -229,9 +206,7 @@ export const WhitespaceNormalizedReplacer: Replacer = function* (
|
||||
// Find the actual substring in the original line that matches
|
||||
const words = find.trim().split(/\s+/)
|
||||
if (words.length > 0) {
|
||||
const pattern = words
|
||||
.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
||||
.join("\\s+")
|
||||
const pattern = words.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\s+")
|
||||
try {
|
||||
const regex = new RegExp(pattern)
|
||||
const match = line.match(regex)
|
||||
@@ -270,9 +245,7 @@ export const IndentationFlexibleReplacer: Replacer = function* (content, find) {
|
||||
}),
|
||||
)
|
||||
|
||||
return lines
|
||||
.map((line) => (line.trim().length === 0 ? line : line.slice(minIndent)))
|
||||
.join("\n")
|
||||
return lines.map((line) => (line.trim().length === 0 ? line : line.slice(minIndent))).join("\n")
|
||||
}
|
||||
|
||||
const normalizedFind = removeIndentation(find)
|
||||
@@ -423,10 +396,7 @@ export const ContextAwareReplacer: Replacer = function* (content, find) {
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
totalNonEmptyLines === 0 ||
|
||||
matchingLines / totalNonEmptyLines >= 0.5
|
||||
) {
|
||||
if (totalNonEmptyLines === 0 || matchingLines / totalNonEmptyLines >= 0.5) {
|
||||
yield block
|
||||
break // Only match the first occurrence
|
||||
}
|
||||
@@ -473,12 +443,7 @@ function trimDiff(diff: string): string {
|
||||
return trimmedLines.join("\n")
|
||||
}
|
||||
|
||||
export function replace(
|
||||
content: string,
|
||||
oldString: string,
|
||||
newString: string,
|
||||
replaceAll = false,
|
||||
): string {
|
||||
export function replace(content: string, oldString: string, newString: string, replaceAll = false): string {
|
||||
if (oldString === newString) {
|
||||
throw new Error("oldString and newString must be different")
|
||||
}
|
||||
@@ -502,11 +467,7 @@ export function replace(
|
||||
}
|
||||
const lastIndex = content.lastIndexOf(search)
|
||||
if (index !== lastIndex) continue
|
||||
return (
|
||||
content.substring(0, index) +
|
||||
newString +
|
||||
content.substring(index + search.length)
|
||||
)
|
||||
return content.substring(0, index) + newString + content.substring(index + search.length)
|
||||
}
|
||||
}
|
||||
throw new Error("oldString not found in content or was found multiple times")
|
||||
|
||||
@@ -20,16 +20,14 @@ export const GlobTool = Tool.define({
|
||||
async execute(params) {
|
||||
const app = App.info()
|
||||
let search = params.path ?? app.path.cwd
|
||||
search = path.isAbsolute(search)
|
||||
? search
|
||||
: path.resolve(app.path.cwd, search)
|
||||
search = path.isAbsolute(search) ? search : path.resolve(app.path.cwd, search)
|
||||
|
||||
const limit = 100
|
||||
const files = []
|
||||
let truncated = false
|
||||
for (const file of await Ripgrep.files({
|
||||
cwd: search,
|
||||
glob: params.pattern,
|
||||
glob: [params.pattern],
|
||||
})) {
|
||||
if (files.length >= limit) {
|
||||
truncated = true
|
||||
@@ -53,17 +51,15 @@ export const GlobTool = Tool.define({
|
||||
output.push(...files.map((f) => f.path))
|
||||
if (truncated) {
|
||||
output.push("")
|
||||
output.push(
|
||||
"(Results are truncated. Consider using a more specific path or pattern.)",
|
||||
)
|
||||
output.push("(Results are truncated. Consider using a more specific path or pattern.)")
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: path.relative(app.path.root, search),
|
||||
metadata: {
|
||||
count: files.length,
|
||||
truncated,
|
||||
title: path.relative(app.path.root, search),
|
||||
},
|
||||
output: output.join("\n"),
|
||||
}
|
||||
|
||||
@@ -9,21 +9,9 @@ export const GrepTool = Tool.define({
|
||||
id: "grep",
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
pattern: z
|
||||
.string()
|
||||
.describe("The regex pattern to search for in file contents"),
|
||||
path: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"The directory to search in. Defaults to the current working directory.",
|
||||
),
|
||||
include: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")',
|
||||
),
|
||||
pattern: z.string().describe("The regex pattern to search for in file contents"),
|
||||
path: z.string().optional().describe("The directory to search in. Defaults to the current working directory."),
|
||||
include: z.string().optional().describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'),
|
||||
}),
|
||||
async execute(params) {
|
||||
if (!params.pattern) {
|
||||
@@ -51,7 +39,8 @@ export const GrepTool = Tool.define({
|
||||
|
||||
if (exitCode === 1) {
|
||||
return {
|
||||
metadata: { matches: 0, truncated: false, title: params.pattern },
|
||||
title: params.pattern,
|
||||
metadata: { matches: 0, truncated: false },
|
||||
output: "No files found",
|
||||
}
|
||||
}
|
||||
@@ -93,7 +82,8 @@ export const GrepTool = Tool.define({
|
||||
|
||||
if (finalMatches.length === 0) {
|
||||
return {
|
||||
metadata: { matches: 0, truncated: false, title: params.pattern },
|
||||
title: params.pattern,
|
||||
metadata: { matches: 0, truncated: false },
|
||||
output: "No files found",
|
||||
}
|
||||
}
|
||||
@@ -114,16 +104,14 @@ export const GrepTool = Tool.define({
|
||||
|
||||
if (truncated) {
|
||||
outputLines.push("")
|
||||
outputLines.push(
|
||||
"(Results are truncated. Consider using a more specific path or pattern.)",
|
||||
)
|
||||
outputLines.push("(Results are truncated. Consider using a more specific path or pattern.)")
|
||||
}
|
||||
|
||||
return {
|
||||
title: params.pattern,
|
||||
metadata: {
|
||||
matches: finalMatches.length,
|
||||
truncated,
|
||||
title: params.pattern,
|
||||
},
|
||||
output: outputLines.join("\n"),
|
||||
}
|
||||
|
||||
@@ -24,16 +24,8 @@ export const ListTool = Tool.define({
|
||||
id: "list",
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
path: z
|
||||
.string()
|
||||
.describe(
|
||||
"The absolute path to the directory to list (must be absolute, not relative)",
|
||||
)
|
||||
.optional(),
|
||||
ignore: z
|
||||
.array(z.string())
|
||||
.describe("List of glob patterns to ignore")
|
||||
.optional(),
|
||||
path: z.string().describe("The absolute path to the directory to list (must be absolute, not relative)").optional(),
|
||||
ignore: z.array(z.string()).describe("List of glob patterns to ignore").optional(),
|
||||
}),
|
||||
async execute(params) {
|
||||
const app = App.info()
|
||||
@@ -44,8 +36,7 @@ export const ListTool = Tool.define({
|
||||
|
||||
for await (const file of glob.scan({ cwd: searchPath, dot: true })) {
|
||||
if (IGNORE_PATTERNS.some((p) => file.includes(p))) continue
|
||||
if (params.ignore?.some((pattern) => new Bun.Glob(pattern).match(file)))
|
||||
continue
|
||||
if (params.ignore?.some((pattern) => new Bun.Glob(pattern).match(file))) continue
|
||||
files.push(file)
|
||||
if (files.length >= LIMIT) break
|
||||
}
|
||||
@@ -99,10 +90,10 @@ export const ListTool = Tool.define({
|
||||
const output = `${searchPath}/\n` + renderDir(".", 0)
|
||||
|
||||
return {
|
||||
title: path.relative(app.path.root, searchPath),
|
||||
metadata: {
|
||||
count: files.length,
|
||||
truncated: files.length >= LIMIT,
|
||||
title: path.relative(app.path.root, searchPath),
|
||||
},
|
||||
output,
|
||||
}
|
||||
|
||||
@@ -13,20 +13,16 @@ export const LspDiagnosticTool = Tool.define({
|
||||
}),
|
||||
execute: async (args) => {
|
||||
const app = App.info()
|
||||
const normalized = path.isAbsolute(args.path)
|
||||
? args.path
|
||||
: path.join(app.path.cwd, args.path)
|
||||
const normalized = path.isAbsolute(args.path) ? args.path : path.join(app.path.cwd, args.path)
|
||||
await LSP.touchFile(normalized, true)
|
||||
const diagnostics = await LSP.diagnostics()
|
||||
const file = diagnostics[normalized]
|
||||
return {
|
||||
title: path.relative(app.path.root, normalized),
|
||||
metadata: {
|
||||
diagnostics,
|
||||
title: path.relative(app.path.root, normalized),
|
||||
},
|
||||
output: file?.length
|
||||
? file.map(LSP.Diagnostic.pretty).join("\n")
|
||||
: "No errors found",
|
||||
output: file?.length ? file.map(LSP.Diagnostic.pretty).join("\n") : "No errors found",
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -15,9 +15,7 @@ export const LspHoverTool = Tool.define({
|
||||
}),
|
||||
execute: async (args) => {
|
||||
const app = App.info()
|
||||
const file = path.isAbsolute(args.file)
|
||||
? args.file
|
||||
: path.join(app.path.cwd, args.file)
|
||||
const file = path.isAbsolute(args.file) ? args.file : path.join(app.path.cwd, args.file)
|
||||
await LSP.touchFile(file, true)
|
||||
const result = await LSP.hover({
|
||||
...args,
|
||||
@@ -25,14 +23,9 @@ export const LspHoverTool = Tool.define({
|
||||
})
|
||||
|
||||
return {
|
||||
title: path.relative(app.path.root, file) + ":" + args.line + ":" + args.character,
|
||||
metadata: {
|
||||
result,
|
||||
title:
|
||||
path.relative(app.path.root, file) +
|
||||
":" +
|
||||
args.line +
|
||||
":" +
|
||||
args.character,
|
||||
},
|
||||
output: JSON.stringify(result, null, 2),
|
||||
}
|
||||
|
||||
@@ -10,9 +10,7 @@ export const MultiEditTool = Tool.define({
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
filePath: z.string().describe("The absolute path to the file to modify"),
|
||||
edits: z
|
||||
.array(EditTool.parameters)
|
||||
.describe("Array of edit operations to perform sequentially on the file"),
|
||||
edits: z.array(EditTool.parameters).describe("Array of edit operations to perform sequentially on the file"),
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
const results = []
|
||||
@@ -30,9 +28,9 @@ export const MultiEditTool = Tool.define({
|
||||
}
|
||||
const app = App.info()
|
||||
return {
|
||||
title: path.relative(app.path.root, params.filePath),
|
||||
metadata: {
|
||||
results: results.map((r) => r.metadata),
|
||||
title: path.relative(app.path.root, params.filePath),
|
||||
},
|
||||
output: results.at(-1)!.output,
|
||||
}
|
||||
|
||||
@@ -6,9 +6,7 @@ import { FileTime } from "../file/time"
|
||||
import DESCRIPTION from "./patch.txt"
|
||||
|
||||
const PatchParams = z.object({
|
||||
patchText: z
|
||||
.string()
|
||||
.describe("The full patch text that describes all changes to be made"),
|
||||
patchText: z.string().describe("The full patch text that describes all changes to be made"),
|
||||
})
|
||||
|
||||
interface Change {
|
||||
@@ -42,10 +40,7 @@ function identifyFilesNeeded(patchText: string): string[] {
|
||||
const files: string[] = []
|
||||
const lines = patchText.split("\n")
|
||||
for (const line of lines) {
|
||||
if (
|
||||
line.startsWith("*** Update File:") ||
|
||||
line.startsWith("*** Delete File:")
|
||||
) {
|
||||
if (line.startsWith("*** Update File:") || line.startsWith("*** Delete File:")) {
|
||||
const filePath = line.split(":", 2)[1]?.trim()
|
||||
if (filePath) files.push(filePath)
|
||||
}
|
||||
@@ -65,10 +60,7 @@ function identifyFilesAdded(patchText: string): string[] {
|
||||
return files
|
||||
}
|
||||
|
||||
function textToPatch(
|
||||
patchText: string,
|
||||
_currentFiles: Record<string, string>,
|
||||
): [PatchOperation[], number] {
|
||||
function textToPatch(patchText: string, _currentFiles: Record<string, string>): [PatchOperation[], number] {
|
||||
const operations: PatchOperation[] = []
|
||||
const lines = patchText.split("\n")
|
||||
let i = 0
|
||||
@@ -93,11 +85,7 @@ function textToPatch(
|
||||
const changes: PatchChange[] = []
|
||||
i++
|
||||
|
||||
while (
|
||||
i < lines.length &&
|
||||
!lines[i].startsWith("@@") &&
|
||||
!lines[i].startsWith("***")
|
||||
) {
|
||||
while (i < lines.length && !lines[i].startsWith("@@") && !lines[i].startsWith("***")) {
|
||||
const changeLine = lines[i]
|
||||
if (changeLine.startsWith(" ")) {
|
||||
changes.push({ type: "keep", content: changeLine.substring(1) })
|
||||
@@ -151,10 +139,7 @@ function textToPatch(
|
||||
return [operations, fuzz]
|
||||
}
|
||||
|
||||
function patchToCommit(
|
||||
operations: PatchOperation[],
|
||||
currentFiles: Record<string, string>,
|
||||
): Commit {
|
||||
function patchToCommit(operations: PatchOperation[], currentFiles: Record<string, string>): Commit {
|
||||
const changes: Record<string, Change> = {}
|
||||
|
||||
for (const op of operations) {
|
||||
@@ -173,9 +158,7 @@ function patchToCommit(
|
||||
const lines = originalContent.split("\n")
|
||||
|
||||
for (const hunk of op.hunks) {
|
||||
const contextIndex = lines.findIndex((line) =>
|
||||
line.includes(hunk.contextLine),
|
||||
)
|
||||
const contextIndex = lines.findIndex((line) => line.includes(hunk.contextLine))
|
||||
if (contextIndex === -1) {
|
||||
throw new Error(`Context line not found: ${hunk.contextLine}`)
|
||||
}
|
||||
@@ -204,11 +187,7 @@ function patchToCommit(
|
||||
return { changes }
|
||||
}
|
||||
|
||||
function generateDiff(
|
||||
oldContent: string,
|
||||
newContent: string,
|
||||
filePath: string,
|
||||
): [string, number, number] {
|
||||
function generateDiff(oldContent: string, newContent: string, filePath: string): [string, number, number] {
|
||||
// Mock implementation - would need actual diff generation
|
||||
const lines1 = oldContent.split("\n")
|
||||
const lines2 = newContent.split("\n")
|
||||
@@ -296,9 +275,7 @@ export const PatchTool = Tool.define({
|
||||
// Process the patch
|
||||
const [patch, fuzz] = textToPatch(params.patchText, currentFiles)
|
||||
if (fuzz > 3) {
|
||||
throw new Error(
|
||||
`patch contains fuzzy matches (fuzz level: ${fuzz}). Please make your context lines more precise`,
|
||||
)
|
||||
throw new Error(`patch contains fuzzy matches (fuzz level: ${fuzz}). Please make your context lines more precise`)
|
||||
}
|
||||
|
||||
// Convert patch to commit
|
||||
@@ -343,11 +320,7 @@ export const PatchTool = Tool.define({
|
||||
const newContent = change.new_content || ""
|
||||
|
||||
// Calculate diff statistics
|
||||
const [, additions, removals] = generateDiff(
|
||||
oldContent,
|
||||
newContent,
|
||||
filePath,
|
||||
)
|
||||
const [, additions, removals] = generateDiff(oldContent, newContent, filePath)
|
||||
totalAdditions += additions
|
||||
totalRemovals += removals
|
||||
|
||||
@@ -358,11 +331,11 @@ export const PatchTool = Tool.define({
|
||||
const output = result
|
||||
|
||||
return {
|
||||
title: `${filesToRead.length} files`,
|
||||
metadata: {
|
||||
changed: changedFiles,
|
||||
additions: totalAdditions,
|
||||
removals: totalRemovals,
|
||||
title: `${filesToRead.length} files`,
|
||||
},
|
||||
output,
|
||||
}
|
||||
|
||||
@@ -16,14 +16,8 @@ export const ReadTool = Tool.define({
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
filePath: z.string().describe("The path to the file to read"),
|
||||
offset: z
|
||||
.number()
|
||||
.describe("The line number to start reading from (0-based)")
|
||||
.optional(),
|
||||
limit: z
|
||||
.number()
|
||||
.describe("The number of lines to read (defaults to 2000)")
|
||||
.optional(),
|
||||
offset: z.number().describe("The line number to start reading from (0-based)").optional(),
|
||||
limit: z.number().describe("The number of lines to read (defaults to 2000)").optional(),
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
let filePath = params.filePath
|
||||
@@ -40,16 +34,13 @@ export const ReadTool = Tool.define({
|
||||
const suggestions = dirEntries
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.toLowerCase().includes(base.toLowerCase()) ||
|
||||
base.toLowerCase().includes(entry.toLowerCase()),
|
||||
entry.toLowerCase().includes(base.toLowerCase()) || base.toLowerCase().includes(entry.toLowerCase()),
|
||||
)
|
||||
.map((entry) => path.join(dir, entry))
|
||||
.slice(0, 3)
|
||||
|
||||
if (suggestions.length > 0) {
|
||||
throw new Error(
|
||||
`File not found: ${filePath}\n\nDid you mean one of these?\n${suggestions.join("\n")}`,
|
||||
)
|
||||
throw new Error(`File not found: ${filePath}\n\nDid you mean one of these?\n${suggestions.join("\n")}`)
|
||||
}
|
||||
|
||||
throw new Error(`File not found: ${filePath}`)
|
||||
@@ -57,21 +48,14 @@ export const ReadTool = Tool.define({
|
||||
const stats = await file.stat()
|
||||
|
||||
if (stats.size > MAX_READ_SIZE)
|
||||
throw new Error(
|
||||
`File is too large (${stats.size} bytes). Maximum size is ${MAX_READ_SIZE} bytes`,
|
||||
)
|
||||
throw new Error(`File is too large (${stats.size} bytes). Maximum size is ${MAX_READ_SIZE} bytes`)
|
||||
const limit = params.limit ?? DEFAULT_READ_LIMIT
|
||||
const offset = params.offset || 0
|
||||
const isImage = isImageFile(filePath)
|
||||
if (isImage)
|
||||
throw new Error(
|
||||
`This is an image file of type: ${isImage}\nUse a different tool to process images`,
|
||||
)
|
||||
if (isImage) throw new Error(`This is an image file of type: ${isImage}\nUse a different tool to process images`)
|
||||
const lines = await file.text().then((text) => text.split("\n"))
|
||||
const raw = lines.slice(offset, offset + limit).map((line) => {
|
||||
return line.length > MAX_LINE_LENGTH
|
||||
? line.substring(0, MAX_LINE_LENGTH) + "..."
|
||||
: line
|
||||
return line.length > MAX_LINE_LENGTH ? line.substring(0, MAX_LINE_LENGTH) + "..." : line
|
||||
})
|
||||
const content = raw.map((line, index) => {
|
||||
return `${(index + offset + 1).toString().padStart(5, "0")}| ${line}`
|
||||
@@ -82,9 +66,7 @@ export const ReadTool = Tool.define({
|
||||
output += content.join("\n")
|
||||
|
||||
if (lines.length > offset + content.length) {
|
||||
output += `\n\n(File has more lines. Use 'offset' parameter to read beyond line ${
|
||||
offset + content.length
|
||||
})`
|
||||
output += `\n\n(File has more lines. Use 'offset' parameter to read beyond line ${offset + content.length})`
|
||||
}
|
||||
output += "\n</file>"
|
||||
|
||||
@@ -93,10 +75,10 @@ export const ReadTool = Tool.define({
|
||||
FileTime.read(ctx.sessionID, filePath)
|
||||
|
||||
return {
|
||||
title: path.relative(App.info().path.root, filePath),
|
||||
output,
|
||||
metadata: {
|
||||
preview,
|
||||
title: path.relative(App.info().path.root, filePath),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@ Reads a file from the local filesystem. You can access any file directly by usin
|
||||
Assume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.
|
||||
|
||||
Usage:
|
||||
- The file_path parameter must be an absolute path, not a relative path
|
||||
- The filePath parameter must be an absolute path, not a relative path
|
||||
- By default, it reads up to 2000 lines starting from the beginning of the file
|
||||
- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters
|
||||
- Any lines longer than 2000 characters will be truncated
|
||||
|
||||
@@ -3,41 +3,36 @@ import DESCRIPTION from "./task.txt"
|
||||
import { z } from "zod"
|
||||
import { Session } from "../session"
|
||||
import { Bus } from "../bus"
|
||||
import { Message } from "../session/message"
|
||||
import { MessageV2 } from "../session/message-v2"
|
||||
|
||||
export const TaskTool = Tool.define({
|
||||
id: "task",
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
description: z
|
||||
.string()
|
||||
.describe("A short (3-5 words) description of the task"),
|
||||
description: z.string().describe("A short (3-5 words) description of the task"),
|
||||
prompt: z.string().describe("The task for the agent to perform"),
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
const session = await Session.create(ctx.sessionID)
|
||||
const msg = await Session.getMessage(ctx.sessionID, ctx.messageID)
|
||||
const metadata = msg.metadata.assistant!
|
||||
const msg = (await Session.getMessage(ctx.sessionID, ctx.messageID)) as MessageV2.Assistant
|
||||
|
||||
function summary(input: Message.Info) {
|
||||
function summary(input: MessageV2.Info) {
|
||||
const result = []
|
||||
|
||||
for (const part of input.parts) {
|
||||
if (part.type === "tool-invocation") {
|
||||
result.push({
|
||||
toolInvocation: part.toolInvocation,
|
||||
metadata: input.metadata.tool[part.toolInvocation.toolCallId],
|
||||
})
|
||||
if (part.type === "tool" && part.state.status === "completed") {
|
||||
result.push(part)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const unsub = Bus.subscribe(Message.Event.Updated, async (evt) => {
|
||||
if (evt.properties.info.metadata.sessionID !== session.id) return
|
||||
const unsub = Bus.subscribe(MessageV2.Event.Updated, async (evt) => {
|
||||
if (evt.properties.info.sessionID !== session.id) return
|
||||
ctx.metadata({
|
||||
title: params.description,
|
||||
summary: summary(evt.properties.info),
|
||||
metadata: {
|
||||
summary: summary(evt.properties.info),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -46,8 +41,8 @@ export const TaskTool = Tool.define({
|
||||
})
|
||||
const result = await Session.chat({
|
||||
sessionID: session.id,
|
||||
modelID: metadata.modelID,
|
||||
providerID: metadata.providerID,
|
||||
modelID: msg.modelID,
|
||||
providerID: msg.providerID,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
@@ -57,8 +52,8 @@ export const TaskTool = Tool.define({
|
||||
})
|
||||
unsub()
|
||||
return {
|
||||
title: params.description,
|
||||
metadata: {
|
||||
title: params.description,
|
||||
summary: summary(result),
|
||||
},
|
||||
output: result.parts.findLast((x) => x.type === "text")!.text,
|
||||
|
||||
@@ -5,12 +5,8 @@ import { App } from "../app/app"
|
||||
|
||||
const TodoInfo = z.object({
|
||||
content: z.string().min(1).describe("Brief description of the task"),
|
||||
status: z
|
||||
.enum(["pending", "in_progress", "completed"])
|
||||
.describe("Current status of the task"),
|
||||
priority: z
|
||||
.enum(["high", "medium", "low"])
|
||||
.describe("Priority level of the task"),
|
||||
status: z.enum(["pending", "in_progress", "completed", "cancelled"]).describe("Current status of the task"),
|
||||
priority: z.enum(["high", "medium", "low"]).describe("Priority level of the task"),
|
||||
id: z.string().describe("Unique identifier for the todo item"),
|
||||
})
|
||||
type TodoInfo = z.infer<typeof TodoInfo>
|
||||
@@ -32,9 +28,9 @@ export const TodoWriteTool = Tool.define({
|
||||
const todos = state()
|
||||
todos[opts.sessionID] = params.todos
|
||||
return {
|
||||
title: `${params.todos.filter((x) => x.status !== "completed").length} todos`,
|
||||
output: JSON.stringify(params.todos, null, 2),
|
||||
metadata: {
|
||||
title: `${params.todos.filter((x) => x.status !== "completed").length} todos`,
|
||||
todos: params.todos,
|
||||
},
|
||||
}
|
||||
@@ -48,9 +44,9 @@ export const TodoReadTool = Tool.define({
|
||||
async execute(_params, opts) {
|
||||
const todos = state()[opts.sessionID] ?? []
|
||||
return {
|
||||
title: `${todos.filter((x) => x.status !== "completed").length} todos`,
|
||||
metadata: {
|
||||
todos,
|
||||
title: `${todos.filter((x) => x.status !== "completed").length} todos`,
|
||||
},
|
||||
output: JSON.stringify(todos, null, 2),
|
||||
}
|
||||
|
||||
@@ -2,19 +2,15 @@ import type { StandardSchemaV1 } from "@standard-schema/spec"
|
||||
|
||||
export namespace Tool {
|
||||
interface Metadata {
|
||||
title: string
|
||||
[key: string]: any
|
||||
}
|
||||
export type Context<M extends Metadata = Metadata> = {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
abort: AbortSignal
|
||||
metadata(meta: M): void
|
||||
metadata(input: { title?: string; metadata?: M }): void
|
||||
}
|
||||
export interface Info<
|
||||
Parameters extends StandardSchemaV1 = StandardSchemaV1,
|
||||
M extends Metadata = Metadata,
|
||||
> {
|
||||
export interface Info<Parameters extends StandardSchemaV1 = StandardSchemaV1, M extends Metadata = Metadata> {
|
||||
id: string
|
||||
description: string
|
||||
parameters: Parameters
|
||||
@@ -22,15 +18,15 @@ export namespace Tool {
|
||||
args: StandardSchemaV1.InferOutput<Parameters>,
|
||||
ctx: Context,
|
||||
): Promise<{
|
||||
title: string
|
||||
metadata: M
|
||||
output: string
|
||||
}>
|
||||
}
|
||||
|
||||
export function define<
|
||||
Parameters extends StandardSchemaV1,
|
||||
Result extends Metadata,
|
||||
>(input: Info<Parameters, Result>): Info<Parameters, Result> {
|
||||
export function define<Parameters extends StandardSchemaV1, Result extends Metadata>(
|
||||
input: Info<Parameters, Result>,
|
||||
): Info<Parameters, Result> {
|
||||
return input
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,9 +14,7 @@ export const WebFetchTool = Tool.define({
|
||||
url: z.string().describe("The URL to fetch content from"),
|
||||
format: z
|
||||
.enum(["text", "markdown", "html"])
|
||||
.describe(
|
||||
"The format to return the content in (text, markdown, or html)",
|
||||
),
|
||||
.describe("The format to return the content in (text, markdown, or html)"),
|
||||
timeout: z
|
||||
.number()
|
||||
.min(0)
|
||||
@@ -26,17 +24,11 @@ export const WebFetchTool = Tool.define({
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
// Validate URL
|
||||
if (
|
||||
!params.url.startsWith("http://") &&
|
||||
!params.url.startsWith("https://")
|
||||
) {
|
||||
if (!params.url.startsWith("http://") && !params.url.startsWith("https://")) {
|
||||
throw new Error("URL must start with http:// or https://")
|
||||
}
|
||||
|
||||
const timeout = Math.min(
|
||||
(params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000,
|
||||
MAX_TIMEOUT,
|
||||
)
|
||||
const timeout = Math.min((params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000, MAX_TIMEOUT)
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout)
|
||||
@@ -46,8 +38,7 @@ export const WebFetchTool = Tool.define({
|
||||
headers: {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
Accept:
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
|
||||
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
},
|
||||
})
|
||||
@@ -79,16 +70,14 @@ export const WebFetchTool = Tool.define({
|
||||
const text = await extractTextFromHTML(content)
|
||||
return {
|
||||
output: text,
|
||||
metadata: {
|
||||
title,
|
||||
},
|
||||
title,
|
||||
metadata: {},
|
||||
}
|
||||
}
|
||||
return {
|
||||
output: content,
|
||||
metadata: {
|
||||
title,
|
||||
},
|
||||
title,
|
||||
metadata: {},
|
||||
}
|
||||
|
||||
case "markdown":
|
||||
@@ -96,32 +85,28 @@ export const WebFetchTool = Tool.define({
|
||||
const markdown = convertHTMLToMarkdown(content)
|
||||
return {
|
||||
output: markdown,
|
||||
metadata: {
|
||||
title,
|
||||
},
|
||||
title,
|
||||
metadata: {},
|
||||
}
|
||||
}
|
||||
return {
|
||||
output: "```\n" + content + "\n```",
|
||||
metadata: {
|
||||
title,
|
||||
},
|
||||
title,
|
||||
metadata: {},
|
||||
}
|
||||
|
||||
case "html":
|
||||
return {
|
||||
output: content,
|
||||
metadata: {
|
||||
title,
|
||||
},
|
||||
title,
|
||||
metadata: {},
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
output: content,
|
||||
metadata: {
|
||||
title,
|
||||
},
|
||||
title,
|
||||
metadata: {},
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -143,16 +128,7 @@ async function extractTextFromHTML(html: string) {
|
||||
.on("*", {
|
||||
element(element) {
|
||||
// Reset skip flag when entering other elements
|
||||
if (
|
||||
![
|
||||
"script",
|
||||
"style",
|
||||
"noscript",
|
||||
"iframe",
|
||||
"object",
|
||||
"embed",
|
||||
].includes(element.tagName)
|
||||
) {
|
||||
if (!["script", "style", "noscript", "iframe", "object", "embed"].includes(element.tagName)) {
|
||||
skipContent = false
|
||||
}
|
||||
},
|
||||
|
||||
@@ -13,18 +13,12 @@ export const WriteTool = Tool.define({
|
||||
id: "write",
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
filePath: z
|
||||
.string()
|
||||
.describe(
|
||||
"The absolute path to the file to write (must be absolute, not relative)",
|
||||
),
|
||||
filePath: z.string().describe("The absolute path to the file to write (must be absolute, not relative)"),
|
||||
content: z.string().describe("The content to write to the file"),
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
const app = App.info()
|
||||
const filepath = path.isAbsolute(params.filePath)
|
||||
? params.filePath
|
||||
: path.join(app.path.cwd, params.filePath)
|
||||
const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(app.path.cwd, params.filePath)
|
||||
|
||||
const file = Bun.file(filepath)
|
||||
const exists = await file.exists()
|
||||
@@ -33,9 +27,7 @@ export const WriteTool = Tool.define({
|
||||
await Permission.ask({
|
||||
id: "write",
|
||||
sessionID: ctx.sessionID,
|
||||
title: exists
|
||||
? "Overwrite this file: " + filepath
|
||||
: "Create new file: " + filepath,
|
||||
title: exists ? "Overwrite this file: " + filepath : "Create new file: " + filepath,
|
||||
metadata: {
|
||||
filePath: filepath,
|
||||
content: params.content,
|
||||
@@ -62,11 +54,11 @@ export const WriteTool = Tool.define({
|
||||
}
|
||||
|
||||
return {
|
||||
title: path.relative(app.path.root, filepath),
|
||||
metadata: {
|
||||
diagnostics,
|
||||
filepath,
|
||||
exists: exists,
|
||||
title: path.relative(app.path.root, filepath),
|
||||
},
|
||||
output,
|
||||
}
|
||||
|
||||
@@ -7,10 +7,7 @@ export abstract class NamedError extends Error {
|
||||
abstract schema(): ZodSchema
|
||||
abstract toObject(): { name: string; data: any }
|
||||
|
||||
static create<Name extends string, Data extends ZodSchema>(
|
||||
name: Name,
|
||||
data: Data,
|
||||
) {
|
||||
static create<Name extends string, Data extends ZodSchema>(name: Name, data: Data) {
|
||||
const schema = z
|
||||
.object({
|
||||
name: z.literal(name),
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { exists } from "fs/promises"
|
||||
import { dirname, join } from "path"
|
||||
import { dirname, join, relative } from "path"
|
||||
|
||||
export namespace Filesystem {
|
||||
export function overlaps(a: string, b: string) {
|
||||
const relA = relative(a, b)
|
||||
const relB = relative(b, a)
|
||||
return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..")
|
||||
}
|
||||
|
||||
export function contains(parent: string, child: string) {
|
||||
return relative(parent, child).startsWith("..")
|
||||
}
|
||||
|
||||
export async function findUp(target: string, start: string, stop?: string) {
|
||||
let current = start
|
||||
const result = []
|
||||
|
||||
@@ -19,10 +19,7 @@ export namespace Log {
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
cleanup(dir)
|
||||
if (options.print) return
|
||||
logpath = path.join(
|
||||
dir,
|
||||
new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log",
|
||||
)
|
||||
logpath = path.join(dir, new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log")
|
||||
const logfile = Bun.file(logpath)
|
||||
await fs.truncate(logpath).catch(() => {})
|
||||
const writer = logfile.writer()
|
||||
@@ -43,9 +40,7 @@ export namespace Log {
|
||||
|
||||
const filesToDelete = files.slice(0, -10)
|
||||
|
||||
await Promise.all(
|
||||
filesToDelete.map((file) => fs.unlink(file).catch(() => {})),
|
||||
)
|
||||
await Promise.all(filesToDelete.map((file) => fs.unlink(file).catch(() => {})))
|
||||
}
|
||||
|
||||
let last = Date.now()
|
||||
@@ -63,11 +58,7 @@ export namespace Log {
|
||||
const next = new Date()
|
||||
const diff = next.getTime() - last
|
||||
last = next.getTime()
|
||||
return (
|
||||
[next.toISOString().split(".")[0], "+" + diff + "ms", prefix, message]
|
||||
.filter(Boolean)
|
||||
.join(" ") + "\n"
|
||||
)
|
||||
return [next.toISOString().split(".")[0], "+" + diff + "ms", prefix, message].filter(Boolean).join(" ") + "\n"
|
||||
}
|
||||
const result = {
|
||||
info(message?: any, extra?: Record<string, any>) {
|
||||
|
||||
@@ -17,12 +17,7 @@ const testCases: TestCase[] = [
|
||||
replace: 'console.log("universe");',
|
||||
},
|
||||
{
|
||||
content: [
|
||||
"if (condition) {",
|
||||
" doSomething();",
|
||||
" doSomethingElse();",
|
||||
"}",
|
||||
].join("\n"),
|
||||
content: ["if (condition) {", " doSomething();", " doSomethingElse();", "}"].join("\n"),
|
||||
find: [" doSomething();", " doSomethingElse();"].join("\n"),
|
||||
replace: [" doNewThing();", " doAnotherThing();"].join("\n"),
|
||||
},
|
||||
@@ -53,15 +48,8 @@ const testCases: TestCase[] = [
|
||||
" return result;",
|
||||
"}",
|
||||
].join("\n"),
|
||||
find: [
|
||||
"function calculate(a, b) {",
|
||||
" // different middle content",
|
||||
" return result;",
|
||||
"}",
|
||||
].join("\n"),
|
||||
replace: ["function calculate(a, b) {", " return a * b * 2;", "}"].join(
|
||||
"\n",
|
||||
),
|
||||
find: ["function calculate(a, b) {", " // different middle content", " return result;", "}"].join("\n"),
|
||||
replace: ["function calculate(a, b) {", " return a * b * 2;", "}"].join("\n"),
|
||||
},
|
||||
{
|
||||
content: [
|
||||
@@ -76,13 +64,7 @@ const testCases: TestCase[] = [
|
||||
"}",
|
||||
].join("\n"),
|
||||
find: ["class MyClass {", " // different implementation", "}"].join("\n"),
|
||||
replace: [
|
||||
"class MyClass {",
|
||||
" constructor() {",
|
||||
" this.value = 42;",
|
||||
" }",
|
||||
"}",
|
||||
].join("\n"),
|
||||
replace: ["class MyClass {", " constructor() {", " this.value = 42;", " }", "}"].join("\n"),
|
||||
},
|
||||
|
||||
// WhitespaceNormalizedReplacer cases
|
||||
@@ -104,48 +86,21 @@ const testCases: TestCase[] = [
|
||||
|
||||
// IndentationFlexibleReplacer cases
|
||||
{
|
||||
content: [
|
||||
" function nested() {",
|
||||
' console.log("deeply nested");',
|
||||
" return true;",
|
||||
" }",
|
||||
].join("\n"),
|
||||
find: [
|
||||
"function nested() {",
|
||||
' console.log("deeply nested");',
|
||||
" return true;",
|
||||
"}",
|
||||
].join("\n"),
|
||||
replace: [
|
||||
"function nested() {",
|
||||
' console.log("updated");',
|
||||
" return false;",
|
||||
"}",
|
||||
].join("\n"),
|
||||
content: [" function nested() {", ' console.log("deeply nested");', " return true;", " }"].join(
|
||||
"\n",
|
||||
),
|
||||
find: ["function nested() {", ' console.log("deeply nested");', " return true;", "}"].join("\n"),
|
||||
replace: ["function nested() {", ' console.log("updated");', " return false;", "}"].join("\n"),
|
||||
},
|
||||
{
|
||||
content: [
|
||||
" if (true) {",
|
||||
' console.log("level 1");',
|
||||
' console.log("level 2");',
|
||||
" }",
|
||||
].join("\n"),
|
||||
find: [
|
||||
"if (true) {",
|
||||
'console.log("level 1");',
|
||||
' console.log("level 2");',
|
||||
"}",
|
||||
].join("\n"),
|
||||
content: [" if (true) {", ' console.log("level 1");', ' console.log("level 2");', " }"].join("\n"),
|
||||
find: ["if (true) {", 'console.log("level 1");', ' console.log("level 2");', "}"].join("\n"),
|
||||
replace: ["if (true) {", 'console.log("updated");', "}"].join("\n"),
|
||||
},
|
||||
|
||||
// replaceAll option cases
|
||||
{
|
||||
content: [
|
||||
'console.log("test");',
|
||||
'console.log("test");',
|
||||
'console.log("test");',
|
||||
].join("\n"),
|
||||
content: ['console.log("test");', 'console.log("test");', 'console.log("test");'].join("\n"),
|
||||
find: 'console.log("test");',
|
||||
replace: 'console.log("updated");',
|
||||
all: true,
|
||||
@@ -213,9 +168,7 @@ const testCases: TestCase[] = [
|
||||
|
||||
// MultiOccurrenceReplacer cases (with replaceAll)
|
||||
{
|
||||
content: ["debug('start');", "debug('middle');", "debug('end');"].join(
|
||||
"\n",
|
||||
),
|
||||
content: ["debug('start');", "debug('middle');", "debug('end');"].join("\n"),
|
||||
find: "debug",
|
||||
replace: "log",
|
||||
all: true,
|
||||
@@ -239,9 +192,7 @@ const testCases: TestCase[] = [
|
||||
replace: "const value = 24;",
|
||||
},
|
||||
{
|
||||
content: ["", " if (condition) {", " doSomething();", " }", ""].join(
|
||||
"\n",
|
||||
),
|
||||
content: ["", " if (condition) {", " doSomething();", " }", ""].join("\n"),
|
||||
find: ["if (condition) {", " doSomething();", "}"].join("\n"),
|
||||
replace: ["if (condition) {", " doNothing();", "}"].join("\n"),
|
||||
},
|
||||
@@ -262,9 +213,7 @@ const testCases: TestCase[] = [
|
||||
" return result;",
|
||||
"}",
|
||||
].join("\n"),
|
||||
replace: ["function calculate(a, b) {", " return (a + b) * 2;", "}"].join(
|
||||
"\n",
|
||||
),
|
||||
replace: ["function calculate(a, b) {", " return (a + b) * 2;", "}"].join("\n"),
|
||||
},
|
||||
{
|
||||
content: [
|
||||
@@ -278,15 +227,8 @@ const testCases: TestCase[] = [
|
||||
" }",
|
||||
"}",
|
||||
].join("\n"),
|
||||
find: [
|
||||
"class TestClass {",
|
||||
" // different implementation",
|
||||
" // with multiple lines",
|
||||
"}",
|
||||
].join("\n"),
|
||||
replace: ["class TestClass {", " getValue() { return 42; }", "}"].join(
|
||||
"\n",
|
||||
),
|
||||
find: ["class TestClass {", " // different implementation", " // with multiple lines", "}"].join("\n"),
|
||||
replace: ["class TestClass {", " getValue() { return 42; }", "}"].join("\n"),
|
||||
},
|
||||
|
||||
// Combined edge cases for new replacers
|
||||
@@ -296,9 +238,7 @@ const testCases: TestCase[] = [
|
||||
replace: 'console.log("updated");',
|
||||
},
|
||||
{
|
||||
content: [" ", "function test() {", " return 'value';", "}", " "].join(
|
||||
"\n",
|
||||
),
|
||||
content: [" ", "function test() {", " return 'value';", "}", " "].join("\n"),
|
||||
find: ["function test() {", "return 'value';", "}"].join("\n"),
|
||||
replace: ["function test() {", "return 'new value';", "}"].join("\n"),
|
||||
},
|
||||
@@ -346,13 +286,7 @@ const testCases: TestCase[] = [
|
||||
|
||||
// ContextAwareReplacer - test with trailing newline in find string
|
||||
{
|
||||
content: [
|
||||
"class Test {",
|
||||
" method1() {",
|
||||
" return 1;",
|
||||
" }",
|
||||
"}",
|
||||
].join("\n"),
|
||||
content: ["class Test {", " method1() {", " return 1;", " }", "}"].join("\n"),
|
||||
find: [
|
||||
"class Test {",
|
||||
" // different content",
|
||||
@@ -401,12 +335,7 @@ describe("EditTool Replacers", () => {
|
||||
replace(testCase.content, testCase.find, testCase.replace, testCase.all)
|
||||
}).toThrow()
|
||||
} else {
|
||||
const result = replace(
|
||||
testCase.content,
|
||||
testCase.find,
|
||||
testCase.replace,
|
||||
testCase.all,
|
||||
)
|
||||
const result = replace(testCase.content, testCase.find, testCase.replace, testCase.all)
|
||||
expect(result).toContain(testCase.replace)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -42,10 +42,7 @@ describe("tool.glob", () => {
|
||||
describe("tool.ls", () => {
|
||||
test("basic", async () => {
|
||||
const result = await App.provide({ cwd: process.cwd() }, async () => {
|
||||
return await ListTool.execute(
|
||||
{ path: "./example", ignore: [".git"] },
|
||||
ctx,
|
||||
)
|
||||
return await ListTool.execute({ path: "./example", ignore: [".git"] }, ctx)
|
||||
})
|
||||
expect(result.output).toMatchSnapshot()
|
||||
})
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [changkun] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
otechie: # Replace with a single Otechie username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
@@ -0,0 +1,71 @@
|
||||
# Copyright 2021 The golang.design Initiative Authors.
|
||||
# All rights reserved. Use of this source code is governed
|
||||
# by a MIT license that can be found in the LICENSE file.
|
||||
#
|
||||
# Written by Changkun Ou <changkun.de>
|
||||
|
||||
name: clipboard
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
platform_test:
|
||||
env:
|
||||
DISPLAY: ':0.0'
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
go: ['1.24.x']
|
||||
steps:
|
||||
- name: Install and run dependencies (xvfb libx11-dev)
|
||||
if: ${{ runner.os == 'Linux' }}
|
||||
run: |
|
||||
sudo apt update
|
||||
sudo apt install -y xvfb libx11-dev x11-utils libegl1-mesa-dev libgles2-mesa-dev
|
||||
Xvfb :0 -screen 0 1024x768x24 > /dev/null 2>&1 &
|
||||
# Wait for Xvfb
|
||||
MAX_ATTEMPTS=120 # About 60 seconds
|
||||
COUNT=0
|
||||
echo -n "Waiting for Xvfb to be ready..."
|
||||
while ! xdpyinfo -display "${DISPLAY}" >/dev/null 2>&1; do
|
||||
echo -n "."
|
||||
sleep 0.50s
|
||||
COUNT=$(( COUNT + 1 ))
|
||||
if [ "${COUNT}" -ge "${MAX_ATTEMPTS}" ]; then
|
||||
echo " Gave up waiting for X server on ${DISPLAY}"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "Done - Xvfb is ready!"
|
||||
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-go@v2
|
||||
with:
|
||||
stable: 'false'
|
||||
go-version: ${{ matrix.go }}
|
||||
|
||||
- name: Build (${{ matrix.go }})
|
||||
run: |
|
||||
go build -o gclip cmd/gclip/main.go
|
||||
go build -o gclip-gui cmd/gclip-gui/main.go
|
||||
|
||||
- name: Run Tests with CGO_ENABLED=1 (${{ matrix.go }})
|
||||
if: ${{ runner.os == 'Linux' || runner.os == 'macOS'}}
|
||||
run: |
|
||||
CGO_ENABLED=1 go test -v -covermode=atomic .
|
||||
|
||||
- name: Run Tests with CGO_ENABLED=0 (${{ matrix.go }})
|
||||
if: ${{ runner.os == 'Linux' || runner.os == 'macOS'}}
|
||||
run: |
|
||||
CGO_ENABLED=0 go test -v -covermode=atomic .
|
||||
|
||||
- name: Run Tests on Windows (${{ matrix.go }})
|
||||
if: ${{ runner.os == 'Windows'}}
|
||||
run: |
|
||||
go test -v -covermode=atomic .
|
||||
@@ -0,0 +1,15 @@
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
# Dependency directories (remove the comment below to include it)
|
||||
# vendor/
|
||||
@@ -0,0 +1,13 @@
|
||||
# Copyright 2021 The golang.design Initiative Authors.
|
||||
# All rights reserved. Use of this source code is governed
|
||||
# by a MIT license that can be found in the LICENSE file.
|
||||
#
|
||||
# Written by Changkun Ou <changkun.de>
|
||||
|
||||
FROM golang:1.24
|
||||
RUN apt-get update && apt-get install -y \
|
||||
xvfb libx11-dev libegl1-mesa-dev libgles2-mesa-dev \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
CMD [ "sh", "-c", "./tests/test-docker.sh" ]
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021 Changkun Ou <contact@changkun.de>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,162 @@
|
||||
# clipboard [](https://pkg.go.dev/golang.design/x/clipboard)  
|
||||
|
||||
Cross platform (macOS/Linux/Windows/Android/iOS) clipboard package in Go
|
||||
|
||||
```go
|
||||
import "golang.design/x/clipboard"
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- Cross platform supports: **macOS, Linux (X11), Windows, iOS, and Android**
|
||||
- Copy/paste UTF-8 text
|
||||
- Copy/paste PNG encoded images (Desktop-only)
|
||||
- Command `gclip` as a demo application
|
||||
- Mobile app `gclip-gui` as a demo application
|
||||
|
||||
## API Usage
|
||||
|
||||
Package clipboard provides cross platform clipboard access and supports
|
||||
macOS/Linux/Windows/Android/iOS platform. Before interacting with the
|
||||
clipboard, one must call Init to assert if it is possible to use this
|
||||
package:
|
||||
|
||||
```go
|
||||
// Init returns an error if the package is not ready for use.
|
||||
err := clipboard.Init()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
```
|
||||
|
||||
The most common operations are `Read` and `Write`. To use them:
|
||||
|
||||
```go
|
||||
// write/read text format data of the clipboard, and
|
||||
// the byte buffer regarding the text are UTF8 encoded.
|
||||
clipboard.Write(clipboard.FmtText, []byte("text data"))
|
||||
clipboard.Read(clipboard.FmtText)
|
||||
|
||||
// write/read image format data of the clipboard, and
|
||||
// the byte buffer regarding the image are PNG encoded.
|
||||
clipboard.Write(clipboard.FmtImage, []byte("image data"))
|
||||
clipboard.Read(clipboard.FmtImage)
|
||||
```
|
||||
|
||||
Note that read/write regarding image format assumes that the bytes are
|
||||
PNG encoded since it serves the alpha blending purpose that might be
|
||||
used in other graphical software.
|
||||
|
||||
In addition, `clipboard.Write` returns a channel that can receive an
|
||||
empty struct as a signal, which indicates the corresponding write call
|
||||
to the clipboard is outdated, meaning the clipboard has been overwritten
|
||||
by others and the previously written data is lost. For instance:
|
||||
|
||||
```go
|
||||
changed := clipboard.Write(clipboard.FmtText, []byte("text data"))
|
||||
|
||||
select {
|
||||
case <-changed:
|
||||
println(`"text data" is no longer available from clipboard.`)
|
||||
}
|
||||
```
|
||||
|
||||
You can ignore the returning channel if you don't need this type of
|
||||
notification. Furthermore, when you need more than just knowing whether
|
||||
clipboard data is changed, use the watcher API:
|
||||
|
||||
```go
|
||||
ch := clipboard.Watch(context.TODO(), clipboard.FmtText)
|
||||
for data := range ch {
|
||||
// print out clipboard data whenever it is changed
|
||||
println(string(data))
|
||||
}
|
||||
```
|
||||
|
||||
## Demos
|
||||
|
||||
- A command line tool `gclip` for command line clipboard accesses, see document [here](./cmd/gclip/README.md).
|
||||
- A GUI application `gclip-gui` for functionality verifications on mobile systems, see a document [here](./cmd/gclip-gui/README.md).
|
||||
|
||||
|
||||
## Command Usage
|
||||
|
||||
`gclip` command offers the ability to interact with the system clipboard
|
||||
from the shell. To install:
|
||||
|
||||
```bash
|
||||
$ go install golang.design/x/clipboard/cmd/gclip@latest
|
||||
```
|
||||
|
||||
```bash
|
||||
$ gclip
|
||||
gclip is a command that provides clipboard interaction.
|
||||
|
||||
usage: gclip [-copy|-paste] [-f <file>]
|
||||
|
||||
options:
|
||||
-copy
|
||||
copy data to clipboard
|
||||
-f string
|
||||
source or destination to a given file path
|
||||
-paste
|
||||
paste data from clipboard
|
||||
|
||||
examples:
|
||||
gclip -paste paste from clipboard and prints the content
|
||||
gclip -paste -f x.txt paste from clipboard and save as text to x.txt
|
||||
gclip -paste -f x.png paste from clipboard and save as image to x.png
|
||||
|
||||
cat x.txt | gclip -copy copy content from x.txt to clipboard
|
||||
gclip -copy -f x.txt copy content from x.txt to clipboard
|
||||
gclip -copy -f x.png copy x.png as image data to clipboard
|
||||
```
|
||||
|
||||
If `-copy` is used, the command will exit when the data is no longer
|
||||
available from the clipboard. You can always send the command to the
|
||||
background using a shell `&` operator, for example:
|
||||
|
||||
```bash
|
||||
$ cat x.txt | gclip -copy &
|
||||
```
|
||||
|
||||
## Platform Specific Details
|
||||
|
||||
This package spent efforts to provide cross platform abstraction regarding
|
||||
accessing system clipboards, but here are a few details you might need to know.
|
||||
|
||||
### Dependency
|
||||
|
||||
- macOS: require Cgo, no dependency
|
||||
- Linux: require X11 dev package. For instance, install `libx11-dev` or `xorg-dev` or `libX11-devel` to access X window system.
|
||||
Wayland sessions are currently unsupported; running under Wayland
|
||||
typically requires an XWayland bridge and `DISPLAY` to be set.
|
||||
- Windows: no Cgo, no dependency
|
||||
- iOS/Android: collaborate with [`gomobile`](https://golang.org/x/mobile)
|
||||
|
||||
### Screenshot
|
||||
|
||||
In general, when you need test your implementation regarding images,
|
||||
There are system level shortcuts to put screenshot image into your system clipboard:
|
||||
|
||||
- On macOS, use `Ctrl+Shift+Cmd+4`
|
||||
- On Linux/Ubuntu, use `Ctrl+Shift+PrintScreen`
|
||||
- On Windows, use `Shift+Win+s`
|
||||
|
||||
As described in the API documentation, the package supports read/write
|
||||
UTF8 encoded plain text or PNG encoded image data. Thus,
|
||||
the other types of data are not supported yet, i.e. undefined behavior.
|
||||
|
||||
## Who is using this package?
|
||||
|
||||
The main purpose of building this package is to support the
|
||||
[midgard](https://changkun.de/s/midgard) project, which offers
|
||||
clipboard-based features like universal clipboard service that syncs
|
||||
clipboard content across multiple systems, allocating public accessible
|
||||
for clipboard content, etc.
|
||||
|
||||
To know more projects, check our [wiki](https://github.com/golang-design/clipboard/wiki) page.
|
||||
|
||||
## License
|
||||
|
||||
MIT | © 2021 The golang.design Initiative Authors, written by [Changkun Ou](https://changkun.de).
|
||||
@@ -0,0 +1,155 @@
|
||||
// Copyright 2021 The golang.design Initiative Authors.
|
||||
// All rights reserved. Use of this source code is governed
|
||||
// by a MIT license that can be found in the LICENSE file.
|
||||
//
|
||||
// Written by Changkun Ou <changkun.de>
|
||||
|
||||
/*
|
||||
Package clipboard provides cross platform clipboard access and supports
|
||||
macOS/Linux/Windows/Android/iOS platform. Before interacting with the
|
||||
clipboard, one must call Init to assert if it is possible to use this
|
||||
package:
|
||||
|
||||
err := clipboard.Init()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
The most common operations are `Read` and `Write`. To use them:
|
||||
|
||||
// write/read text format data of the clipboard, and
|
||||
// the byte buffer regarding the text are UTF8 encoded.
|
||||
clipboard.Write(clipboard.FmtText, []byte("text data"))
|
||||
clipboard.Read(clipboard.FmtText)
|
||||
|
||||
// write/read image format data of the clipboard, and
|
||||
// the byte buffer regarding the image are PNG encoded.
|
||||
clipboard.Write(clipboard.FmtImage, []byte("image data"))
|
||||
clipboard.Read(clipboard.FmtImage)
|
||||
|
||||
Note that read/write regarding image format assumes that the bytes are
|
||||
PNG encoded since it serves the alpha blending purpose that might be
|
||||
used in other graphical software.
|
||||
|
||||
In addition, `clipboard.Write` returns a channel that can receive an
|
||||
empty struct as a signal, which indicates the corresponding write call
|
||||
to the clipboard is outdated, meaning the clipboard has been overwritten
|
||||
by others and the previously written data is lost. For instance:
|
||||
|
||||
changed := clipboard.Write(clipboard.FmtText, []byte("text data"))
|
||||
|
||||
select {
|
||||
case <-changed:
|
||||
println(`"text data" is no longer available from clipboard.`)
|
||||
}
|
||||
|
||||
You can ignore the returning channel if you don't need this type of
|
||||
notification. Furthermore, when you need more than just knowing whether
|
||||
clipboard data is changed, use the watcher API:
|
||||
|
||||
ch := clipboard.Watch(context.TODO(), clipboard.FmtText)
|
||||
for data := range ch {
|
||||
// print out clipboard data whenever it is changed
|
||||
println(string(data))
|
||||
}
|
||||
*/
|
||||
package clipboard // import "golang.design/x/clipboard"
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
// activate only for running tests.
|
||||
debug = false
|
||||
errUnavailable = errors.New("clipboard unavailable")
|
||||
errUnsupported = errors.New("unsupported format")
|
||||
errNoCgo = errors.New("clipboard: cannot use when CGO_ENABLED=0")
|
||||
)
|
||||
|
||||
// Format represents the format of clipboard data.
|
||||
type Format int
|
||||
|
||||
// All sorts of supported clipboard data
|
||||
const (
|
||||
// FmtText indicates plain text clipboard format
|
||||
FmtText Format = iota
|
||||
// FmtImage indicates image/png clipboard format
|
||||
FmtImage
|
||||
)
|
||||
|
||||
var (
|
||||
// Due to the limitation on operating systems (such as darwin),
|
||||
// concurrent read can even cause panic, use a global lock to
|
||||
// guarantee one read at a time.
|
||||
lock = sync.Mutex{}
|
||||
initOnce sync.Once
|
||||
initError error
|
||||
)
|
||||
|
||||
// Init initializes the clipboard package. It returns an error
|
||||
// if the clipboard is not available to use. This may happen if the
|
||||
// target system lacks required dependency, such as libx11-dev in X11
|
||||
// environment. For example,
|
||||
//
|
||||
// err := clipboard.Init()
|
||||
// if err != nil {
|
||||
// panic(err)
|
||||
// }
|
||||
//
|
||||
// If Init returns an error, any subsequent Read/Write/Watch call
|
||||
// may result in an unrecoverable panic.
|
||||
func Init() error {
|
||||
initOnce.Do(func() {
|
||||
initError = initialize()
|
||||
})
|
||||
return initError
|
||||
}
|
||||
|
||||
// Read returns a chunk of bytes of the clipboard data if it presents
|
||||
// in the desired format t presents. Otherwise, it returns nil.
|
||||
func Read(t Format) []byte {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
buf, err := read(t)
|
||||
if err != nil {
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "read clipboard err: %v\n", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return buf
|
||||
}
|
||||
|
||||
// Write writes a given buffer to the clipboard in a specified format.
|
||||
// Write returned a receive-only channel can receive an empty struct
|
||||
// as a signal, which indicates the clipboard has been overwritten from
|
||||
// this write.
|
||||
// If format t indicates an image, then the given buf assumes
|
||||
// the image data is PNG encoded.
|
||||
func Write(t Format, buf []byte) <-chan struct{} {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
changed, err := write(t, buf)
|
||||
if err != nil {
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "write to clipboard err: %v\n", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// Watch returns a receive-only channel that received the clipboard data
|
||||
// whenever any change of clipboard data in the desired format happens.
|
||||
//
|
||||
// The returned channel will be closed if the given context is canceled.
|
||||
func Watch(ctx context.Context, t Format) <-chan []byte {
|
||||
return watch(ctx, t)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Copyright 2021 The golang.design Initiative Authors.
|
||||
// All rights reserved. Use of this source code is governed
|
||||
// by a MIT license that can be found in the LICENSE file.
|
||||
//
|
||||
// Written by Changkun Ou <changkun.de>
|
||||
|
||||
//go:build android
|
||||
|
||||
#include <android/log.h>
|
||||
#include <jni.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#define LOG_FATAL(...) __android_log_print(ANDROID_LOG_FATAL, \
|
||||
"GOLANG.DESIGN/X/CLIPBOARD", __VA_ARGS__)
|
||||
|
||||
static jmethodID find_method(JNIEnv *env, jclass clazz, const char *name, const char *sig) {
|
||||
jmethodID m = (*env)->GetMethodID(env, clazz, name, sig);
|
||||
if (m == 0) {
|
||||
(*env)->ExceptionClear(env);
|
||||
LOG_FATAL("cannot find method %s %s", name, sig);
|
||||
return 0;
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
jobject get_clipboard(uintptr_t jni_env, uintptr_t ctx) {
|
||||
JNIEnv *env = (JNIEnv*)jni_env;
|
||||
jclass ctxClass = (*env)->GetObjectClass(env, (jobject)ctx);
|
||||
jmethodID getSystemService = find_method(env, ctxClass, "getSystemService", "(Ljava/lang/String;)Ljava/lang/Object;");
|
||||
|
||||
jstring service = (*env)->NewStringUTF(env, "clipboard");
|
||||
jobject ret = (jobject)(*env)->CallObjectMethod(env, (jobject)ctx, getSystemService, service);
|
||||
jthrowable err = (*env)->ExceptionOccurred(env);
|
||||
|
||||
if (err != NULL) {
|
||||
LOG_FATAL("cannot find clipboard");
|
||||
(*env)->ExceptionClear(env);
|
||||
return NULL;
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
char *clipboard_read_string(uintptr_t java_vm, uintptr_t jni_env, uintptr_t ctx) {
|
||||
JNIEnv *env = (JNIEnv*)jni_env;
|
||||
jobject mgr = get_clipboard(jni_env, ctx);
|
||||
if (mgr == NULL) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
jclass mgrClass = (*env)->GetObjectClass(env, mgr);
|
||||
jmethodID getText = find_method(env, mgrClass, "getText", "()Ljava/lang/CharSequence;");
|
||||
|
||||
jobject content = (jstring)(*env)->CallObjectMethod(env, mgr, getText);
|
||||
if (content == NULL) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
jclass clzCharSequence = (*env)->GetObjectClass(env, content);
|
||||
jmethodID toString = (*env)->GetMethodID(env, clzCharSequence, "toString", "()Ljava/lang/String;");
|
||||
jobject s = (*env)->CallObjectMethod(env, content, toString);
|
||||
|
||||
const char *chars = (*env)->GetStringUTFChars(env, s, NULL);
|
||||
char *copy = strdup(chars);
|
||||
(*env)->ReleaseStringUTFChars(env, s, chars);
|
||||
return copy;
|
||||
}
|
||||
|
||||
void clipboard_write_string(uintptr_t java_vm, uintptr_t jni_env, uintptr_t ctx, char *str) {
|
||||
JNIEnv *env = (JNIEnv*)jni_env;
|
||||
jobject mgr = get_clipboard(jni_env, ctx);
|
||||
if (mgr == NULL) {
|
||||
return;
|
||||
}
|
||||
|
||||
jclass mgrClass = (*env)->GetObjectClass(env, mgr);
|
||||
jmethodID setText = find_method(env, mgrClass, "setText", "(Ljava/lang/CharSequence;)V");
|
||||
|
||||
(*env)->CallVoidMethod(env, mgr, setText, (*env)->NewStringUTF(env, str));
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// Copyright 2021 The golang.design Initiative Authors.
|
||||
// All rights reserved. Use of this source code is governed
|
||||
// by a MIT license that can be found in the LICENSE file.
|
||||
//
|
||||
// Written by Changkun Ou <changkun.de>
|
||||
|
||||
//go:build android
|
||||
|
||||
package clipboard
|
||||
|
||||
/*
|
||||
#cgo LDFLAGS: -landroid -llog
|
||||
|
||||
#include <stdlib.h>
|
||||
char *clipboard_read_string(uintptr_t java_vm, uintptr_t jni_env, uintptr_t ctx);
|
||||
void clipboard_write_string(uintptr_t java_vm, uintptr_t jni_env, uintptr_t ctx, char *str);
|
||||
|
||||
*/
|
||||
import "C"
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/mobile/app"
|
||||
)
|
||||
|
||||
func initialize() error { return nil }
|
||||
|
||||
func read(t Format) (buf []byte, err error) {
|
||||
switch t {
|
||||
case FmtText:
|
||||
s := ""
|
||||
if err := app.RunOnJVM(func(vm, env, ctx uintptr) error {
|
||||
cs := C.clipboard_read_string(C.uintptr_t(vm), C.uintptr_t(env), C.uintptr_t(ctx))
|
||||
if cs == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
s = C.GoString(cs)
|
||||
C.free(unsafe.Pointer(cs))
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []byte(s), nil
|
||||
case FmtImage:
|
||||
return nil, errUnsupported
|
||||
default:
|
||||
return nil, errUnsupported
|
||||
}
|
||||
}
|
||||
|
||||
// write writes the given data to clipboard and
|
||||
// returns true if success or false if failed.
|
||||
func write(t Format, buf []byte) (<-chan struct{}, error) {
|
||||
done := make(chan struct{}, 1)
|
||||
switch t {
|
||||
case FmtText:
|
||||
cs := C.CString(string(buf))
|
||||
defer C.free(unsafe.Pointer(cs))
|
||||
|
||||
if err := app.RunOnJVM(func(vm, env, ctx uintptr) error {
|
||||
C.clipboard_write_string(C.uintptr_t(vm), C.uintptr_t(env), C.uintptr_t(ctx), cs)
|
||||
done <- struct{}{}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return done, nil
|
||||
case FmtImage:
|
||||
return nil, errUnsupported
|
||||
default:
|
||||
return nil, errUnsupported
|
||||
}
|
||||
}
|
||||
|
||||
func watch(ctx context.Context, t Format) <-chan []byte {
|
||||
recv := make(chan []byte, 1)
|
||||
ti := time.NewTicker(time.Second)
|
||||
last := Read(t)
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
close(recv)
|
||||
return
|
||||
case <-ti.C:
|
||||
b := Read(t)
|
||||
if b == nil {
|
||||
continue
|
||||
}
|
||||
if bytes.Compare(last, b) != 0 {
|
||||
recv <- b
|
||||
last = b
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return recv
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
// Copyright 2021 The golang.design Initiative Authors.
|
||||
// All rights reserved. Use of this source code is governed
|
||||
// by a MIT license that can be found in the LICENSE file.
|
||||
//
|
||||
// Written by Changkun Ou <changkun.de>
|
||||
|
||||
//go:build darwin && !ios
|
||||
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
lastChangeCount int64
|
||||
changeCountMu sync.Mutex
|
||||
)
|
||||
|
||||
func initialize() error { return nil }
|
||||
|
||||
func read(t Format) (buf []byte, err error) {
|
||||
switch t {
|
||||
case FmtText:
|
||||
return readText()
|
||||
case FmtImage:
|
||||
return readImage()
|
||||
default:
|
||||
return nil, errUnsupported
|
||||
}
|
||||
}
|
||||
|
||||
func readText() ([]byte, error) {
|
||||
// Check if clipboard contains string data
|
||||
checkScript := `
|
||||
try
|
||||
set clipboardTypes to (clipboard info)
|
||||
repeat with aType in clipboardTypes
|
||||
if (first item of aType) is string then
|
||||
return "hastext"
|
||||
end if
|
||||
end repeat
|
||||
return "notext"
|
||||
on error
|
||||
return "error"
|
||||
end try
|
||||
`
|
||||
|
||||
cmd := exec.Command("osascript", "-e", checkScript)
|
||||
checkOut, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, errUnavailable
|
||||
}
|
||||
|
||||
checkOut = bytes.TrimSpace(checkOut)
|
||||
if !bytes.Equal(checkOut, []byte("hastext")) {
|
||||
return nil, errUnavailable
|
||||
}
|
||||
|
||||
// Now get the actual text
|
||||
cmd = exec.Command("osascript", "-e", "get the clipboard")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, errUnavailable
|
||||
}
|
||||
// Remove trailing newline that osascript adds
|
||||
out = bytes.TrimSuffix(out, []byte("\n"))
|
||||
|
||||
// If clipboard was set to empty string, return nil
|
||||
if len(out) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func readImage() ([]byte, error) {
|
||||
// AppleScript to read image data from clipboard as base64
|
||||
script := `
|
||||
try
|
||||
set theData to the clipboard as «class PNGf»
|
||||
return theData
|
||||
on error
|
||||
return ""
|
||||
end try
|
||||
`
|
||||
|
||||
cmd := exec.Command("osascript", "-e", script)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, errUnavailable
|
||||
}
|
||||
|
||||
// Check if we got any data
|
||||
out = bytes.TrimSpace(out)
|
||||
if len(out) == 0 {
|
||||
return nil, errUnavailable
|
||||
}
|
||||
|
||||
// The output is in hex format (e.g., «data PNGf89504E...»)
|
||||
// We need to extract and convert it
|
||||
outStr := string(out)
|
||||
if !strings.HasPrefix(outStr, "«data PNGf") || !strings.HasSuffix(outStr, "»") {
|
||||
return nil, errUnavailable
|
||||
}
|
||||
|
||||
// Extract hex data
|
||||
hexData := strings.TrimPrefix(outStr, "«data PNGf")
|
||||
hexData = strings.TrimSuffix(hexData, "»")
|
||||
|
||||
// Convert hex to bytes
|
||||
buf := make([]byte, len(hexData)/2)
|
||||
for i := 0; i < len(hexData); i += 2 {
|
||||
b, err := strconv.ParseUint(hexData[i:i+2], 16, 8)
|
||||
if err != nil {
|
||||
return nil, errUnavailable
|
||||
}
|
||||
buf[i/2] = byte(b)
|
||||
}
|
||||
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
// write writes the given data to clipboard and
|
||||
// returns true if success or false if failed.
|
||||
func write(t Format, buf []byte) (<-chan struct{}, error) {
|
||||
var err error
|
||||
switch t {
|
||||
case FmtText:
|
||||
err = writeText(buf)
|
||||
case FmtImage:
|
||||
err = writeImage(buf)
|
||||
default:
|
||||
return nil, errUnsupported
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Update change count
|
||||
changeCountMu.Lock()
|
||||
lastChangeCount++
|
||||
currentCount := lastChangeCount
|
||||
changeCountMu.Unlock()
|
||||
|
||||
// use unbuffered channel to prevent goroutine leak
|
||||
changed := make(chan struct{}, 1)
|
||||
go func() {
|
||||
for {
|
||||
time.Sleep(time.Second)
|
||||
changeCountMu.Lock()
|
||||
if lastChangeCount != currentCount {
|
||||
changeCountMu.Unlock()
|
||||
changed <- struct{}{}
|
||||
close(changed)
|
||||
return
|
||||
}
|
||||
changeCountMu.Unlock()
|
||||
}
|
||||
}()
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
func writeText(buf []byte) error {
|
||||
if len(buf) == 0 {
|
||||
// Clear clipboard
|
||||
script := `set the clipboard to ""`
|
||||
cmd := exec.Command("osascript", "-e", script)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return errUnavailable
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Escape the text for AppleScript
|
||||
text := string(buf)
|
||||
text = strings.ReplaceAll(text, "\\", "\\\\")
|
||||
text = strings.ReplaceAll(text, "\"", "\\\"")
|
||||
|
||||
script := fmt.Sprintf(`set the clipboard to "%s"`, text)
|
||||
cmd := exec.Command("osascript", "-e", script)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return errUnavailable
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func writeImage(buf []byte) error {
|
||||
if len(buf) == 0 {
|
||||
// Clear clipboard
|
||||
script := `set the clipboard to ""`
|
||||
cmd := exec.Command("osascript", "-e", script)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return errUnavailable
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create a temporary file to store the PNG data
|
||||
tmpFile, err := os.CreateTemp("", "clipboard*.png")
|
||||
if err != nil {
|
||||
return errUnavailable
|
||||
}
|
||||
defer os.Remove(tmpFile.Name())
|
||||
|
||||
if _, err := tmpFile.Write(buf); err != nil {
|
||||
tmpFile.Close()
|
||||
return errUnavailable
|
||||
}
|
||||
tmpFile.Close()
|
||||
|
||||
// Use osascript to set clipboard to the image file
|
||||
script := fmt.Sprintf(`
|
||||
set theFile to POSIX file "%s"
|
||||
set theImage to read theFile as «class PNGf»
|
||||
set the clipboard to theImage
|
||||
`, tmpFile.Name())
|
||||
|
||||
cmd := exec.Command("osascript", "-e", script)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return errUnavailable
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func watch(ctx context.Context, t Format) <-chan []byte {
|
||||
recv := make(chan []byte, 1)
|
||||
ti := time.NewTicker(time.Second)
|
||||
|
||||
// Get initial clipboard content
|
||||
var lastContent []byte
|
||||
if b := Read(t); b != nil {
|
||||
lastContent = make([]byte, len(b))
|
||||
copy(lastContent, b)
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer close(recv)
|
||||
defer ti.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ti.C:
|
||||
b := Read(t)
|
||||
if b == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if content changed
|
||||
if !bytes.Equal(lastContent, b) {
|
||||
recv <- b
|
||||
lastContent = make([]byte, len(b))
|
||||
copy(lastContent, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return recv
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Copyright 2021 The golang.design Initiative Authors.
|
||||
// All rights reserved. Use of this source code is governed
|
||||
// by a MIT license that can be found in the LICENSE file.
|
||||
//
|
||||
// Written by Changkun Ou <changkun.de>
|
||||
|
||||
//go:build ios
|
||||
|
||||
package clipboard
|
||||
|
||||
/*
|
||||
#cgo CFLAGS: -x objective-c
|
||||
#cgo LDFLAGS: -framework Foundation -framework UIKit -framework MobileCoreServices
|
||||
|
||||
#import <stdlib.h>
|
||||
void clipboard_write_string(char *s);
|
||||
char *clipboard_read_string();
|
||||
*/
|
||||
import "C"
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"time"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
func initialize() error { return nil }
|
||||
|
||||
func read(t Format) (buf []byte, err error) {
|
||||
switch t {
|
||||
case FmtText:
|
||||
return []byte(C.GoString(C.clipboard_read_string())), nil
|
||||
case FmtImage:
|
||||
return nil, errUnsupported
|
||||
default:
|
||||
return nil, errUnsupported
|
||||
}
|
||||
}
|
||||
|
||||
// SetContent sets the clipboard content for iOS
|
||||
func write(t Format, buf []byte) (<-chan struct{}, error) {
|
||||
done := make(chan struct{}, 1)
|
||||
switch t {
|
||||
case FmtText:
|
||||
cs := C.CString(string(buf))
|
||||
defer C.free(unsafe.Pointer(cs))
|
||||
|
||||
C.clipboard_write_string(cs)
|
||||
return done, nil
|
||||
case FmtImage:
|
||||
return nil, errUnsupported
|
||||
default:
|
||||
return nil, errUnsupported
|
||||
}
|
||||
}
|
||||
|
||||
func watch(ctx context.Context, t Format) <-chan []byte {
|
||||
recv := make(chan []byte, 1)
|
||||
ti := time.NewTicker(time.Second)
|
||||
last := Read(t)
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
close(recv)
|
||||
return
|
||||
case <-ti.C:
|
||||
b := Read(t)
|
||||
if b == nil {
|
||||
continue
|
||||
}
|
||||
if bytes.Compare(last, b) != 0 {
|
||||
recv <- b
|
||||
last = b
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return recv
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copyright 2021 The golang.design Initiative Authors.
|
||||
// All rights reserved. Use of this source code is governed
|
||||
// by a MIT license that can be found in the LICENSE file.
|
||||
//
|
||||
// Written by Changkun Ou <changkun.de>
|
||||
|
||||
//go:build ios
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
#import <MobileCoreServices/MobileCoreServices.h>
|
||||
|
||||
void clipboard_write_string(char *s) {
|
||||
NSString *value = [NSString stringWithUTF8String:s];
|
||||
[[UIPasteboard generalPasteboard] setString:value];
|
||||
}
|
||||
|
||||
char *clipboard_read_string() {
|
||||
NSString *str = [[UIPasteboard generalPasteboard] string];
|
||||
return (char *)[str UTF8String];
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
// Copyright 2021 The golang.design Initiative Authors.
|
||||
// All rights reserved. Use of this source code is governed
|
||||
// by a MIT license that can be found in the LICENSE file.
|
||||
//
|
||||
// Written by Changkun Ou <changkun.de>
|
||||
|
||||
//go:build linux && !android
|
||||
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
// Clipboard tools in order of preference
|
||||
clipboardTools = []struct {
|
||||
name string
|
||||
readCmd []string
|
||||
writeCmd []string
|
||||
readImg []string
|
||||
writeImg []string
|
||||
available bool
|
||||
}{
|
||||
{
|
||||
name: "xclip",
|
||||
readCmd: []string{"xclip", "-selection", "clipboard", "-o"},
|
||||
writeCmd: []string{"xclip", "-selection", "clipboard"},
|
||||
readImg: []string{"xclip", "-selection", "clipboard", "-t", "image/png", "-o"},
|
||||
writeImg: []string{"xclip", "-selection", "clipboard", "-t", "image/png"},
|
||||
},
|
||||
{
|
||||
name: "xsel",
|
||||
readCmd: []string{"xsel", "--clipboard", "--output"},
|
||||
writeCmd: []string{"xsel", "--clipboard", "--input"},
|
||||
readImg: []string{"xsel", "--clipboard", "--output"},
|
||||
writeImg: []string{"xsel", "--clipboard", "--input"},
|
||||
},
|
||||
{
|
||||
name: "wl-clipboard",
|
||||
readCmd: []string{"wl-paste", "-n"},
|
||||
writeCmd: []string{"wl-copy"},
|
||||
readImg: []string{"wl-paste", "-t", "image/png", "-n"},
|
||||
writeImg: []string{"wl-copy", "-t", "image/png"},
|
||||
},
|
||||
}
|
||||
|
||||
selectedTool int = -1
|
||||
toolMutex sync.Mutex
|
||||
lastChangeTime time.Time
|
||||
changeTimeMu sync.Mutex
|
||||
)
|
||||
|
||||
func initialize() error {
|
||||
toolMutex.Lock()
|
||||
defer toolMutex.Unlock()
|
||||
|
||||
if selectedTool >= 0 {
|
||||
return nil // Already initialized
|
||||
}
|
||||
|
||||
// Check which clipboard tool is available
|
||||
for i, tool := range clipboardTools {
|
||||
cmd := exec.Command("which", tool.name)
|
||||
if err := cmd.Run(); err == nil {
|
||||
clipboardTools[i].available = true
|
||||
if selectedTool < 0 {
|
||||
selectedTool = i
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if selectedTool < 0 {
|
||||
return fmt.Errorf(`%w: No clipboard utility found. Install one of the following:
|
||||
|
||||
For X11 systems:
|
||||
apt install -y xclip
|
||||
# or
|
||||
apt install -y xsel
|
||||
|
||||
For Wayland systems:
|
||||
apt install -y wl-clipboard
|
||||
|
||||
If running in a headless environment, you may also need:
|
||||
apt install -y xvfb
|
||||
# and run:
|
||||
Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 &
|
||||
export DISPLAY=:99.0`, errUnavailable)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func read(t Format) (buf []byte, err error) {
|
||||
toolMutex.Lock()
|
||||
tool := clipboardTools[selectedTool]
|
||||
toolMutex.Unlock()
|
||||
|
||||
switch t {
|
||||
case FmtText:
|
||||
return readText(tool)
|
||||
case FmtImage:
|
||||
return readImage(tool)
|
||||
default:
|
||||
return nil, errUnsupported
|
||||
}
|
||||
}
|
||||
|
||||
func readText(tool struct {
|
||||
name string
|
||||
readCmd []string
|
||||
writeCmd []string
|
||||
readImg []string
|
||||
writeImg []string
|
||||
available bool
|
||||
}) ([]byte, error) {
|
||||
// First check if clipboard contains text
|
||||
cmd := exec.Command(tool.readCmd[0], tool.readCmd[1:]...)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
// Check if it's because clipboard contains non-text data
|
||||
if tool.name == "xclip" {
|
||||
// xclip returns error when clipboard doesn't contain requested type
|
||||
checkCmd := exec.Command("xclip", "-selection", "clipboard", "-t", "TARGETS", "-o")
|
||||
targets, _ := checkCmd.Output()
|
||||
if bytes.Contains(targets, []byte("image/png")) && !bytes.Contains(targets, []byte("UTF8_STRING")) {
|
||||
return nil, errUnavailable
|
||||
}
|
||||
}
|
||||
return nil, errUnavailable
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func readImage(tool struct {
|
||||
name string
|
||||
readCmd []string
|
||||
writeCmd []string
|
||||
readImg []string
|
||||
writeImg []string
|
||||
available bool
|
||||
}) ([]byte, error) {
|
||||
if tool.name == "xsel" {
|
||||
// xsel doesn't support image types well, return error
|
||||
return nil, errUnavailable
|
||||
}
|
||||
|
||||
cmd := exec.Command(tool.readImg[0], tool.readImg[1:]...)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, errUnavailable
|
||||
}
|
||||
|
||||
// Verify it's PNG data
|
||||
if len(out) < 8 || !bytes.Equal(out[:8], []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}) {
|
||||
return nil, errUnavailable
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func write(t Format, buf []byte) (<-chan struct{}, error) {
|
||||
toolMutex.Lock()
|
||||
tool := clipboardTools[selectedTool]
|
||||
toolMutex.Unlock()
|
||||
|
||||
var cmd *exec.Cmd
|
||||
switch t {
|
||||
case FmtText:
|
||||
if len(buf) == 0 {
|
||||
// Write empty string
|
||||
cmd = exec.Command(tool.writeCmd[0], tool.writeCmd[1:]...)
|
||||
cmd.Stdin = bytes.NewReader([]byte{})
|
||||
} else {
|
||||
cmd = exec.Command(tool.writeCmd[0], tool.writeCmd[1:]...)
|
||||
cmd.Stdin = bytes.NewReader(buf)
|
||||
}
|
||||
case FmtImage:
|
||||
if tool.name == "xsel" {
|
||||
// xsel doesn't support image types well
|
||||
return nil, errUnavailable
|
||||
}
|
||||
if len(buf) == 0 {
|
||||
// Clear clipboard
|
||||
cmd = exec.Command(tool.writeCmd[0], tool.writeCmd[1:]...)
|
||||
cmd.Stdin = bytes.NewReader([]byte{})
|
||||
} else {
|
||||
cmd = exec.Command(tool.writeImg[0], tool.writeImg[1:]...)
|
||||
cmd.Stdin = bytes.NewReader(buf)
|
||||
}
|
||||
default:
|
||||
return nil, errUnsupported
|
||||
}
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, errUnavailable
|
||||
}
|
||||
|
||||
// Update change time
|
||||
changeTimeMu.Lock()
|
||||
lastChangeTime = time.Now()
|
||||
currentTime := lastChangeTime
|
||||
changeTimeMu.Unlock()
|
||||
|
||||
// Create change notification channel
|
||||
changed := make(chan struct{}, 1)
|
||||
go func() {
|
||||
for {
|
||||
time.Sleep(time.Second)
|
||||
changeTimeMu.Lock()
|
||||
if !lastChangeTime.Equal(currentTime) {
|
||||
changeTimeMu.Unlock()
|
||||
changed <- struct{}{}
|
||||
close(changed)
|
||||
return
|
||||
}
|
||||
changeTimeMu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
func watch(ctx context.Context, t Format) <-chan []byte {
|
||||
recv := make(chan []byte, 1)
|
||||
ti := time.NewTicker(time.Second)
|
||||
|
||||
// Get initial clipboard content
|
||||
var lastContent []byte
|
||||
if b := Read(t); b != nil {
|
||||
lastContent = make([]byte, len(b))
|
||||
copy(lastContent, b)
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer close(recv)
|
||||
defer ti.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ti.C:
|
||||
b := Read(t)
|
||||
if b == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if content changed
|
||||
if !bytes.Equal(lastContent, b) {
|
||||
recv <- b
|
||||
lastContent = make([]byte, len(b))
|
||||
copy(lastContent, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return recv
|
||||
}
|
||||
|
||||
// Helper function to check clipboard content type for xclip
|
||||
func getClipboardTargets() []string {
|
||||
cmd := exec.Command("xclip", "-selection", "clipboard", "-t", "TARGETS", "-o")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(string(out), "\n")
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//go:build !windows && !darwin && !linux && !cgo
|
||||
|
||||
package clipboard
|
||||
|
||||
import "context"
|
||||
|
||||
func initialize() error {
|
||||
return errNoCgo
|
||||
}
|
||||
|
||||
func read(t Format) (buf []byte, err error) {
|
||||
panic("clipboard: cannot use when CGO_ENABLED=0")
|
||||
}
|
||||
|
||||
func readc(t string) ([]byte, error) {
|
||||
panic("clipboard: cannot use when CGO_ENABLED=0")
|
||||
}
|
||||
|
||||
func write(t Format, buf []byte) (<-chan struct{}, error) {
|
||||
panic("clipboard: cannot use when CGO_ENABLED=0")
|
||||
}
|
||||
|
||||
func watch(ctx context.Context, t Format) <-chan []byte {
|
||||
panic("clipboard: cannot use when CGO_ENABLED=0")
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
// Copyright 2021 The golang.design Initiative Authors.
|
||||
// All rights reserved. Use of this source code is governed
|
||||
// by a MIT license that can be found in the LICENSE file.
|
||||
//
|
||||
// Written by Changkun Ou <changkun.de>
|
||||
|
||||
package clipboard_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"os"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.design/x/clipboard"
|
||||
)
|
||||
|
||||
func init() {
|
||||
clipboard.Debug = true
|
||||
}
|
||||
|
||||
func TestClipboardInit(t *testing.T) {
|
||||
t.Run("no-cgo", func(t *testing.T) {
|
||||
if val, ok := os.LookupEnv("CGO_ENABLED"); !ok || val != "0" {
|
||||
t.Skip("CGO_ENABLED is set to 1")
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows does not need to check for cgo")
|
||||
}
|
||||
|
||||
if err := clipboard.Init(); !errors.Is(err, clipboard.ErrCgoDisabled) {
|
||||
t.Fatalf("expect ErrCgoDisabled, got: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("with-cgo", func(t *testing.T) {
|
||||
if val, ok := os.LookupEnv("CGO_ENABLED"); ok && val == "0" {
|
||||
t.Skip("CGO_ENABLED is set to 0")
|
||||
}
|
||||
if runtime.GOOS != "linux" {
|
||||
t.Skip("Only Linux may return error at the moment.")
|
||||
}
|
||||
|
||||
if err := clipboard.Init(); err != nil && !errors.Is(err, clipboard.ErrUnavailable) {
|
||||
t.Fatalf("expect ErrUnavailable, but got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestClipboard(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
if val, ok := os.LookupEnv("CGO_ENABLED"); ok && val == "0" {
|
||||
t.Skip("CGO_ENABLED is set to 0")
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("image", func(t *testing.T) {
|
||||
data, err := os.ReadFile("tests/testdata/clipboard.png")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read gold file: %v", err)
|
||||
}
|
||||
clipboard.Write(clipboard.FmtImage, data)
|
||||
|
||||
b := clipboard.Read(clipboard.FmtText)
|
||||
if b != nil {
|
||||
t.Fatalf("read clipboard that stores image data as text should fail, but got len: %d", len(b))
|
||||
}
|
||||
|
||||
b = clipboard.Read(clipboard.FmtImage)
|
||||
if b == nil {
|
||||
t.Fatalf("read clipboard that stores image data as image should success, but got: nil")
|
||||
}
|
||||
|
||||
img1, err := png.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatalf("write image is not png encoded: %v", err)
|
||||
}
|
||||
img2, err := png.Decode(bytes.NewReader(b))
|
||||
if err != nil {
|
||||
t.Fatalf("read image is not png encoded: %v", err)
|
||||
}
|
||||
|
||||
w := img2.Bounds().Dx()
|
||||
h := img2.Bounds().Dy()
|
||||
|
||||
incorrect := 0
|
||||
for i := 0; i < w; i++ {
|
||||
for j := 0; j < h; j++ {
|
||||
wr, wg, wb, wa := img1.At(i, j).RGBA()
|
||||
gr, gg, gb, ga := img2.At(i, j).RGBA()
|
||||
want := color.RGBA{
|
||||
R: uint8(wr),
|
||||
G: uint8(wg),
|
||||
B: uint8(wb),
|
||||
A: uint8(wa),
|
||||
}
|
||||
got := color.RGBA{
|
||||
R: uint8(gr),
|
||||
G: uint8(gg),
|
||||
B: uint8(gb),
|
||||
A: uint8(ga),
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(want, got) {
|
||||
t.Logf("read data from clipbaord is inconsistent with previous written data, pix: (%d,%d), got: %+v, want: %+v", i, j, got, want)
|
||||
incorrect++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if incorrect > 0 {
|
||||
t.Fatalf("read data from clipboard contains too much inconsistent pixels to the previous written data, number of incorrect pixels: %v", incorrect)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("text", func(t *testing.T) {
|
||||
data := []byte("golang.design/x/clipboard")
|
||||
clipboard.Write(clipboard.FmtText, data)
|
||||
|
||||
b := clipboard.Read(clipboard.FmtImage)
|
||||
if b != nil {
|
||||
t.Fatalf("read clipboard that stores text data as image should fail, but got len: %d", len(b))
|
||||
}
|
||||
b = clipboard.Read(clipboard.FmtText)
|
||||
if b == nil {
|
||||
t.Fatal("read clipboard taht stores text data as text should success, but got: nil")
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(data, b) {
|
||||
t.Fatalf("read data from clipbaord is inconsistent with previous written data, got: %d, want: %d", len(b), len(data))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestClipboardMultipleWrites(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
if val, ok := os.LookupEnv("CGO_ENABLED"); ok && val == "0" {
|
||||
t.Skip("CGO_ENABLED is set to 0")
|
||||
}
|
||||
}
|
||||
|
||||
data, err := os.ReadFile("tests/testdata/clipboard.png")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read gold file: %v", err)
|
||||
}
|
||||
chg := clipboard.Write(clipboard.FmtImage, data)
|
||||
|
||||
data = []byte("golang.design/x/clipboard")
|
||||
clipboard.Write(clipboard.FmtText, data)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*2)
|
||||
defer cancel()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Fatalf("failed to receive clipboard change notification")
|
||||
case _, ok := <-chg:
|
||||
if !ok {
|
||||
t.Fatalf("change channel is closed before receiving the changed clipboard data")
|
||||
}
|
||||
}
|
||||
_, ok := <-chg
|
||||
if ok {
|
||||
t.Fatalf("changed channel should be closed after receiving the notification")
|
||||
}
|
||||
|
||||
b := clipboard.Read(clipboard.FmtImage)
|
||||
if b != nil {
|
||||
t.Fatalf("read clipboard that should store text data as image should fail, but got: %d", len(b))
|
||||
}
|
||||
|
||||
b = clipboard.Read(clipboard.FmtText)
|
||||
if b == nil {
|
||||
t.Fatalf("read clipboard that should store text data as text should success, got: nil")
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(data, b) {
|
||||
t.Fatalf("read data from clipbaord is inconsistent with previous write, want %s, got: %s", string(data), string(b))
|
||||
}
|
||||
}
|
||||
|
||||
func TestClipboardConcurrentRead(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
if val, ok := os.LookupEnv("CGO_ENABLED"); ok && val == "0" {
|
||||
t.Skip("CGO_ENABLED is set to 0")
|
||||
}
|
||||
}
|
||||
|
||||
// This test check that concurrent read/write to the clipboard does
|
||||
// not cause crashes on some specific platform, such as macOS.
|
||||
done := make(chan bool, 2)
|
||||
go func() {
|
||||
defer func() {
|
||||
done <- true
|
||||
}()
|
||||
clipboard.Read(clipboard.FmtText)
|
||||
}()
|
||||
go func() {
|
||||
defer func() {
|
||||
done <- true
|
||||
}()
|
||||
clipboard.Read(clipboard.FmtImage)
|
||||
}()
|
||||
<-done
|
||||
<-done
|
||||
}
|
||||
|
||||
func TestClipboardWriteEmpty(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
if val, ok := os.LookupEnv("CGO_ENABLED"); ok && val == "0" {
|
||||
t.Skip("CGO_ENABLED is set to 0")
|
||||
}
|
||||
}
|
||||
|
||||
chg1 := clipboard.Write(clipboard.FmtText, nil)
|
||||
if got := clipboard.Read(clipboard.FmtText); got != nil {
|
||||
t.Fatalf("write nil to clipboard should read nil, got: %v", string(got))
|
||||
}
|
||||
clipboard.Write(clipboard.FmtText, []byte(""))
|
||||
<-chg1
|
||||
|
||||
if got := clipboard.Read(clipboard.FmtText); string(got) != "" {
|
||||
t.Fatalf("write empty string to clipboard should read empty string, got: `%v`", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestClipboardWatch(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
if val, ok := os.LookupEnv("CGO_ENABLED"); ok && val == "0" {
|
||||
t.Skip("CGO_ENABLED is set to 0")
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*2)
|
||||
defer cancel()
|
||||
|
||||
// clear clipboard
|
||||
clipboard.Write(clipboard.FmtText, []byte(""))
|
||||
lastRead := clipboard.Read(clipboard.FmtText)
|
||||
|
||||
changed := clipboard.Watch(ctx, clipboard.FmtText)
|
||||
|
||||
want := []byte("golang.design/x/clipboard")
|
||||
go func(ctx context.Context) {
|
||||
t := time.NewTicker(time.Millisecond * 500)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
clipboard.Write(clipboard.FmtText, want)
|
||||
}
|
||||
}
|
||||
}(ctx)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if string(lastRead) == "" {
|
||||
t.Fatalf("clipboard watch never receives a notification")
|
||||
}
|
||||
t.Log(string(lastRead))
|
||||
return
|
||||
case data, ok := <-changed:
|
||||
if !ok {
|
||||
if string(lastRead) == "" {
|
||||
t.Fatalf("clipboard watch never receives a notification")
|
||||
}
|
||||
return
|
||||
}
|
||||
if !bytes.Equal(data, want) {
|
||||
t.Fatalf("received data from watch mismatch, want: %v, got %v", string(want), string(data))
|
||||
}
|
||||
lastRead = data
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkClipboard(b *testing.B) {
|
||||
b.Run("text", func(b *testing.B) {
|
||||
data := []byte("golang.design/x/clipboard")
|
||||
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
clipboard.Write(clipboard.FmtText, data)
|
||||
_ = clipboard.Read(clipboard.FmtText)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestClipboardNoCgo(t *testing.T) {
|
||||
if val, ok := os.LookupEnv("CGO_ENABLED"); !ok || val != "0" {
|
||||
t.Skip("CGO_ENABLED is set to 1")
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows should always be tested")
|
||||
}
|
||||
|
||||
t.Run("Read", func(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
return
|
||||
}
|
||||
t.Fatalf("expect to fail when CGO_ENABLED=0")
|
||||
}()
|
||||
|
||||
clipboard.Read(clipboard.FmtText)
|
||||
})
|
||||
|
||||
t.Run("Write", func(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
return
|
||||
}
|
||||
t.Fatalf("expect to fail when CGO_ENABLED=0")
|
||||
}()
|
||||
|
||||
clipboard.Write(clipboard.FmtText, []byte("dummy"))
|
||||
})
|
||||
|
||||
t.Run("Watch", func(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
return
|
||||
}
|
||||
t.Fatalf("expect to fail when CGO_ENABLED=0")
|
||||
}()
|
||||
|
||||
clipboard.Watch(context.TODO(), clipboard.FmtText)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,551 @@
|
||||
// Copyright 2021 The golang.design Initiative Authors.
|
||||
// All rights reserved. Use of this source code is governed
|
||||
// by a MIT license that can be found in the LICENSE file.
|
||||
//
|
||||
// Written by Changkun Ou <changkun.de>
|
||||
|
||||
//go:build windows
|
||||
|
||||
package clipboard
|
||||
|
||||
// Interacting with Clipboard on Windows:
|
||||
// https://docs.microsoft.com/zh-cn/windows/win32/dataxchg/using-the-clipboard
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"syscall"
|
||||
"time"
|
||||
"unicode/utf16"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/image/bmp"
|
||||
)
|
||||
|
||||
func initialize() error { return nil }
|
||||
|
||||
// readText reads the clipboard and returns the text data if presents.
|
||||
// The caller is responsible for opening/closing the clipboard before
|
||||
// calling this function.
|
||||
func readText() (buf []byte, err error) {
|
||||
hMem, _, err := getClipboardData.Call(cFmtUnicodeText)
|
||||
if hMem == 0 {
|
||||
return nil, err
|
||||
}
|
||||
p, _, err := gLock.Call(hMem)
|
||||
if p == 0 {
|
||||
return nil, err
|
||||
}
|
||||
defer gUnlock.Call(hMem)
|
||||
|
||||
// Find NUL terminator
|
||||
n := 0
|
||||
for ptr := unsafe.Pointer(p); *(*uint16)(ptr) != 0; n++ {
|
||||
ptr = unsafe.Pointer(uintptr(ptr) +
|
||||
unsafe.Sizeof(*((*uint16)(unsafe.Pointer(p)))))
|
||||
}
|
||||
|
||||
var s []uint16
|
||||
h := (*reflect.SliceHeader)(unsafe.Pointer(&s))
|
||||
h.Data = p
|
||||
h.Len = n
|
||||
h.Cap = n
|
||||
return []byte(string(utf16.Decode(s))), nil
|
||||
}
|
||||
|
||||
// writeText writes given data to the clipboard. It is the caller's
|
||||
// responsibility for opening/closing the clipboard before calling
|
||||
// this function.
|
||||
func writeText(buf []byte) error {
|
||||
r, _, err := emptyClipboard.Call()
|
||||
if r == 0 {
|
||||
return fmt.Errorf("failed to clear clipboard: %w", err)
|
||||
}
|
||||
|
||||
// empty text, we are done here.
|
||||
if len(buf) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
s, err := syscall.UTF16FromString(string(buf))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to convert given string: %w", err)
|
||||
}
|
||||
|
||||
hMem, _, err := gAlloc.Call(gmemMoveable, uintptr(len(s)*int(unsafe.Sizeof(s[0]))))
|
||||
if hMem == 0 {
|
||||
return fmt.Errorf("failed to alloc global memory: %w", err)
|
||||
}
|
||||
|
||||
p, _, err := gLock.Call(hMem)
|
||||
if p == 0 {
|
||||
return fmt.Errorf("failed to lock global memory: %w", err)
|
||||
}
|
||||
defer gUnlock.Call(hMem)
|
||||
|
||||
// no return value
|
||||
memMove.Call(p, uintptr(unsafe.Pointer(&s[0])),
|
||||
uintptr(len(s)*int(unsafe.Sizeof(s[0]))))
|
||||
|
||||
v, _, err := setClipboardData.Call(cFmtUnicodeText, hMem)
|
||||
if v == 0 {
|
||||
gFree.Call(hMem)
|
||||
return fmt.Errorf("failed to set text to clipboard: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// readImage reads the clipboard and returns PNG encoded image data
|
||||
// if presents. The caller is responsible for opening/closing the
|
||||
// clipboard before calling this function.
|
||||
func readImage() ([]byte, error) {
|
||||
hMem, _, err := getClipboardData.Call(cFmtDIBV5)
|
||||
if hMem == 0 {
|
||||
// second chance to try FmtDIB
|
||||
return readImageDib()
|
||||
}
|
||||
p, _, err := gLock.Call(hMem)
|
||||
if p == 0 {
|
||||
return nil, err
|
||||
}
|
||||
defer gUnlock.Call(hMem)
|
||||
|
||||
// inspect header information
|
||||
info := (*bitmapV5Header)(unsafe.Pointer(p))
|
||||
|
||||
// maybe deal with other formats?
|
||||
if info.BitCount != 32 {
|
||||
return nil, errUnsupported
|
||||
}
|
||||
|
||||
var data []byte
|
||||
sh := (*reflect.SliceHeader)(unsafe.Pointer(&data))
|
||||
sh.Data = uintptr(p)
|
||||
sh.Cap = int(info.Size + 4*uint32(info.Width)*uint32(info.Height))
|
||||
sh.Len = int(info.Size + 4*uint32(info.Width)*uint32(info.Height))
|
||||
img := image.NewRGBA(image.Rect(0, 0, int(info.Width), int(info.Height)))
|
||||
offset := int(info.Size)
|
||||
stride := int(info.Width)
|
||||
for y := 0; y < int(info.Height); y++ {
|
||||
for x := 0; x < int(info.Width); x++ {
|
||||
idx := offset + 4*(y*stride+x)
|
||||
xhat := (x + int(info.Width)) % int(info.Width)
|
||||
yhat := int(info.Height) - 1 - y
|
||||
r := data[idx+2]
|
||||
g := data[idx+1]
|
||||
b := data[idx+0]
|
||||
a := data[idx+3]
|
||||
img.SetRGBA(xhat, yhat, color.RGBA{r, g, b, a})
|
||||
}
|
||||
}
|
||||
// always use PNG encoding.
|
||||
var buf bytes.Buffer
|
||||
png.Encode(&buf, img)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func readImageDib() ([]byte, error) {
|
||||
const (
|
||||
fileHeaderLen = 14
|
||||
infoHeaderLen = 40
|
||||
cFmtDIB = 8
|
||||
)
|
||||
|
||||
hClipDat, _, err := getClipboardData.Call(cFmtDIB)
|
||||
if err != nil {
|
||||
return nil, errors.New("not dib format data: " + err.Error())
|
||||
}
|
||||
pMemBlk, _, err := gLock.Call(hClipDat)
|
||||
if pMemBlk == 0 {
|
||||
return nil, errors.New("failed to call global lock: " + err.Error())
|
||||
}
|
||||
defer gUnlock.Call(hClipDat)
|
||||
|
||||
bmpHeader := (*bitmapHeader)(unsafe.Pointer(pMemBlk))
|
||||
dataSize := bmpHeader.SizeImage + fileHeaderLen + infoHeaderLen
|
||||
|
||||
if bmpHeader.SizeImage == 0 && bmpHeader.Compression == 0 {
|
||||
iSizeImage := bmpHeader.Height * ((bmpHeader.Width*uint32(bmpHeader.BitCount)/8 + 3) &^ 3)
|
||||
dataSize += iSizeImage
|
||||
}
|
||||
buf := new(bytes.Buffer)
|
||||
binary.Write(buf, binary.LittleEndian, uint16('B')|(uint16('M')<<8))
|
||||
binary.Write(buf, binary.LittleEndian, uint32(dataSize))
|
||||
binary.Write(buf, binary.LittleEndian, uint32(0))
|
||||
const sizeof_colorbar = 0
|
||||
binary.Write(buf, binary.LittleEndian, uint32(fileHeaderLen+infoHeaderLen+sizeof_colorbar))
|
||||
j := 0
|
||||
for i := fileHeaderLen; i < int(dataSize); i++ {
|
||||
binary.Write(buf, binary.BigEndian, *(*byte)(unsafe.Pointer(pMemBlk + uintptr(j))))
|
||||
j++
|
||||
}
|
||||
return bmpToPng(buf)
|
||||
}
|
||||
|
||||
func bmpToPng(bmpBuf *bytes.Buffer) (buf []byte, err error) {
|
||||
var f bytes.Buffer
|
||||
original_image, err := bmp.Decode(bmpBuf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = png.Encode(&f, original_image)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return f.Bytes(), nil
|
||||
}
|
||||
|
||||
func writeImage(buf []byte) error {
|
||||
r, _, err := emptyClipboard.Call()
|
||||
if r == 0 {
|
||||
return fmt.Errorf("failed to clear clipboard: %w", err)
|
||||
}
|
||||
|
||||
// empty text, we are done here.
|
||||
if len(buf) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
img, err := png.Decode(bytes.NewReader(buf))
|
||||
if err != nil {
|
||||
return fmt.Errorf("input bytes is not PNG encoded: %w", err)
|
||||
}
|
||||
|
||||
offset := unsafe.Sizeof(bitmapV5Header{})
|
||||
width := img.Bounds().Dx()
|
||||
height := img.Bounds().Dy()
|
||||
imageSize := 4 * width * height
|
||||
|
||||
data := make([]byte, int(offset)+imageSize)
|
||||
for y := 0; y < height; y++ {
|
||||
for x := 0; x < width; x++ {
|
||||
idx := int(offset) + 4*(y*width+x)
|
||||
r, g, b, a := img.At(x, height-1-y).RGBA()
|
||||
data[idx+2] = uint8(r)
|
||||
data[idx+1] = uint8(g)
|
||||
data[idx+0] = uint8(b)
|
||||
data[idx+3] = uint8(a)
|
||||
}
|
||||
}
|
||||
|
||||
info := bitmapV5Header{}
|
||||
info.Size = uint32(offset)
|
||||
info.Width = int32(width)
|
||||
info.Height = int32(height)
|
||||
info.Planes = 1
|
||||
info.Compression = 0 // BI_RGB
|
||||
info.SizeImage = uint32(4 * info.Width * info.Height)
|
||||
info.RedMask = 0xff0000 // default mask
|
||||
info.GreenMask = 0xff00
|
||||
info.BlueMask = 0xff
|
||||
info.AlphaMask = 0xff000000
|
||||
info.BitCount = 32 // we only deal with 32 bpp at the moment.
|
||||
// Use calibrated RGB values as Go's image/png assumes linear color space.
|
||||
// Other options:
|
||||
// - LCS_CALIBRATED_RGB = 0x00000000
|
||||
// - LCS_sRGB = 0x73524742
|
||||
// - LCS_WINDOWS_COLOR_SPACE = 0x57696E20
|
||||
// https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-wmf/eb4bbd50-b3ce-4917-895c-be31f214797f
|
||||
info.CSType = 0x73524742
|
||||
// Use GL_IMAGES for GamutMappingIntent
|
||||
// Other options:
|
||||
// - LCS_GM_ABS_COLORIMETRIC = 0x00000008
|
||||
// - LCS_GM_BUSINESS = 0x00000001
|
||||
// - LCS_GM_GRAPHICS = 0x00000002
|
||||
// - LCS_GM_IMAGES = 0x00000004
|
||||
// https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-wmf/9fec0834-607d-427d-abd5-ab240fb0db38
|
||||
info.Intent = 4 // LCS_GM_IMAGES
|
||||
|
||||
infob := make([]byte, int(unsafe.Sizeof(info)))
|
||||
for i, v := range *(*[unsafe.Sizeof(info)]byte)(unsafe.Pointer(&info)) {
|
||||
infob[i] = v
|
||||
}
|
||||
copy(data[:], infob[:])
|
||||
|
||||
hMem, _, err := gAlloc.Call(gmemMoveable,
|
||||
uintptr(len(data)*int(unsafe.Sizeof(data[0]))))
|
||||
if hMem == 0 {
|
||||
return fmt.Errorf("failed to alloc global memory: %w", err)
|
||||
}
|
||||
|
||||
p, _, err := gLock.Call(hMem)
|
||||
if p == 0 {
|
||||
return fmt.Errorf("failed to lock global memory: %w", err)
|
||||
}
|
||||
defer gUnlock.Call(hMem)
|
||||
|
||||
memMove.Call(p, uintptr(unsafe.Pointer(&data[0])),
|
||||
uintptr(len(data)*int(unsafe.Sizeof(data[0]))))
|
||||
|
||||
v, _, err := setClipboardData.Call(cFmtDIBV5, hMem)
|
||||
if v == 0 {
|
||||
gFree.Call(hMem)
|
||||
return fmt.Errorf("failed to set text to clipboard: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func read(t Format) (buf []byte, err error) {
|
||||
// On Windows, OpenClipboard and CloseClipboard must be executed on
|
||||
// the same thread. Thus, lock the OS thread for further execution.
|
||||
runtime.LockOSThread()
|
||||
defer runtime.UnlockOSThread()
|
||||
|
||||
var format uintptr
|
||||
switch t {
|
||||
case FmtImage:
|
||||
format = cFmtDIBV5
|
||||
case FmtText:
|
||||
fallthrough
|
||||
default:
|
||||
format = cFmtUnicodeText
|
||||
}
|
||||
|
||||
// check if clipboard is avaliable for the requested format
|
||||
r, _, err := isClipboardFormatAvailable.Call(format)
|
||||
if r == 0 {
|
||||
return nil, errUnavailable
|
||||
}
|
||||
|
||||
// try again until open clipboard successed
|
||||
for {
|
||||
r, _, _ = openClipboard.Call()
|
||||
if r == 0 {
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
defer closeClipboard.Call()
|
||||
|
||||
switch format {
|
||||
case cFmtDIBV5:
|
||||
return readImage()
|
||||
case cFmtUnicodeText:
|
||||
fallthrough
|
||||
default:
|
||||
return readText()
|
||||
}
|
||||
}
|
||||
|
||||
// write writes the given data to clipboard and
|
||||
// returns true if success or false if failed.
|
||||
func write(t Format, buf []byte) (<-chan struct{}, error) {
|
||||
errch := make(chan error)
|
||||
changed := make(chan struct{}, 1)
|
||||
go func() {
|
||||
// make sure GetClipboardSequenceNumber happens with
|
||||
// OpenClipboard on the same thread.
|
||||
runtime.LockOSThread()
|
||||
defer runtime.UnlockOSThread()
|
||||
for {
|
||||
r, _, _ := openClipboard.Call(0)
|
||||
if r == 0 {
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// var param uintptr
|
||||
switch t {
|
||||
case FmtImage:
|
||||
err := writeImage(buf)
|
||||
if err != nil {
|
||||
errch <- err
|
||||
closeClipboard.Call()
|
||||
return
|
||||
}
|
||||
case FmtText:
|
||||
fallthrough
|
||||
default:
|
||||
// param = cFmtUnicodeText
|
||||
err := writeText(buf)
|
||||
if err != nil {
|
||||
errch <- err
|
||||
closeClipboard.Call()
|
||||
return
|
||||
}
|
||||
}
|
||||
// Close the clipboard otherwise other applications cannot
|
||||
// paste the data.
|
||||
closeClipboard.Call()
|
||||
|
||||
cnt, _, _ := getClipboardSequenceNumber.Call()
|
||||
errch <- nil
|
||||
for {
|
||||
time.Sleep(time.Second)
|
||||
cur, _, _ := getClipboardSequenceNumber.Call()
|
||||
if cur != cnt {
|
||||
changed <- struct{}{}
|
||||
close(changed)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
err := <-errch
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
func watch(ctx context.Context, t Format) <-chan []byte {
|
||||
recv := make(chan []byte, 1)
|
||||
ready := make(chan struct{})
|
||||
go func() {
|
||||
// not sure if we are too slow or the user too fast :)
|
||||
ti := time.NewTicker(time.Second)
|
||||
cnt, _, _ := getClipboardSequenceNumber.Call()
|
||||
ready <- struct{}{}
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
close(recv)
|
||||
return
|
||||
case <-ti.C:
|
||||
cur, _, _ := getClipboardSequenceNumber.Call()
|
||||
if cnt != cur {
|
||||
b := Read(t)
|
||||
if b == nil {
|
||||
continue
|
||||
}
|
||||
recv <- b
|
||||
cnt = cur
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
<-ready
|
||||
return recv
|
||||
}
|
||||
|
||||
const (
|
||||
cFmtBitmap = 2 // Win+PrintScreen
|
||||
cFmtUnicodeText = 13
|
||||
cFmtDIBV5 = 17
|
||||
// Screenshot taken from special shortcut is in different format (why??), see:
|
||||
// https://jpsoft.com/forums/threads/detecting-clipboard-format.5225/
|
||||
cFmtDataObject = 49161 // Shift+Win+s, returned from enumClipboardFormats
|
||||
gmemMoveable = 0x0002
|
||||
)
|
||||
|
||||
// BITMAPV5Header structure, see:
|
||||
// https://docs.microsoft.com/en-us/windows/win32/api/wingdi/ns-wingdi-bitmapv5header
|
||||
type bitmapV5Header struct {
|
||||
Size uint32
|
||||
Width int32
|
||||
Height int32
|
||||
Planes uint16
|
||||
BitCount uint16
|
||||
Compression uint32
|
||||
SizeImage uint32
|
||||
XPelsPerMeter int32
|
||||
YPelsPerMeter int32
|
||||
ClrUsed uint32
|
||||
ClrImportant uint32
|
||||
RedMask uint32
|
||||
GreenMask uint32
|
||||
BlueMask uint32
|
||||
AlphaMask uint32
|
||||
CSType uint32
|
||||
Endpoints struct {
|
||||
CiexyzRed, CiexyzGreen, CiexyzBlue struct {
|
||||
CiexyzX, CiexyzY, CiexyzZ int32 // FXPT2DOT30
|
||||
}
|
||||
}
|
||||
GammaRed uint32
|
||||
GammaGreen uint32
|
||||
GammaBlue uint32
|
||||
Intent uint32
|
||||
ProfileData uint32
|
||||
ProfileSize uint32
|
||||
Reserved uint32
|
||||
}
|
||||
|
||||
type bitmapHeader struct {
|
||||
Size uint32
|
||||
Width uint32
|
||||
Height uint32
|
||||
PLanes uint16
|
||||
BitCount uint16
|
||||
Compression uint32
|
||||
SizeImage uint32
|
||||
XPelsPerMeter uint32
|
||||
YPelsPerMeter uint32
|
||||
ClrUsed uint32
|
||||
ClrImportant uint32
|
||||
}
|
||||
|
||||
// Calling a Windows DLL, see:
|
||||
// https://github.com/golang/go/wiki/WindowsDLLs
|
||||
var (
|
||||
user32 = syscall.MustLoadDLL("user32")
|
||||
// Opens the clipboard for examination and prevents other
|
||||
// applications from modifying the clipboard content.
|
||||
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-openclipboard
|
||||
openClipboard = user32.MustFindProc("OpenClipboard")
|
||||
// Closes the clipboard.
|
||||
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-closeclipboard
|
||||
closeClipboard = user32.MustFindProc("CloseClipboard")
|
||||
// Empties the clipboard and frees handles to data in the clipboard.
|
||||
// The function then assigns ownership of the clipboard to the
|
||||
// window that currently has the clipboard open.
|
||||
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-emptyclipboard
|
||||
emptyClipboard = user32.MustFindProc("EmptyClipboard")
|
||||
// Retrieves data from the clipboard in a specified format.
|
||||
// The clipboard must have been opened previously.
|
||||
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getclipboarddata
|
||||
getClipboardData = user32.MustFindProc("GetClipboardData")
|
||||
// Places data on the clipboard in a specified clipboard format.
|
||||
// The window must be the current clipboard owner, and the
|
||||
// application must have called the OpenClipboard function. (When
|
||||
// responding to the WM_RENDERFORMAT message, the clipboard owner
|
||||
// must not call OpenClipboard before calling SetClipboardData.)
|
||||
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setclipboarddata
|
||||
setClipboardData = user32.MustFindProc("SetClipboardData")
|
||||
// Determines whether the clipboard contains data in the specified format.
|
||||
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-isclipboardformatavailable
|
||||
isClipboardFormatAvailable = user32.MustFindProc("IsClipboardFormatAvailable")
|
||||
// Clipboard data formats are stored in an ordered list. To perform
|
||||
// an enumeration of clipboard data formats, you make a series of
|
||||
// calls to the EnumClipboardFormats function. For each call, the
|
||||
// format parameter specifies an available clipboard format, and the
|
||||
// function returns the next available clipboard format.
|
||||
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-isclipboardformatavailable
|
||||
enumClipboardFormats = user32.MustFindProc("EnumClipboardFormats")
|
||||
// Retrieves the clipboard sequence number for the current window station.
|
||||
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getclipboardsequencenumber
|
||||
getClipboardSequenceNumber = user32.MustFindProc("GetClipboardSequenceNumber")
|
||||
// Registers a new clipboard format. This format can then be used as
|
||||
// a valid clipboard format.
|
||||
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-registerclipboardformata
|
||||
registerClipboardFormatA = user32.MustFindProc("RegisterClipboardFormatA")
|
||||
|
||||
kernel32 = syscall.NewLazyDLL("kernel32")
|
||||
|
||||
// Locks a global memory object and returns a pointer to the first
|
||||
// byte of the object's memory block.
|
||||
// https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-globallock
|
||||
gLock = kernel32.NewProc("GlobalLock")
|
||||
// Decrements the lock count associated with a memory object that was
|
||||
// allocated with GMEM_MOVEABLE. This function has no effect on memory
|
||||
// objects allocated with GMEM_FIXED.
|
||||
// https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-globalunlock
|
||||
gUnlock = kernel32.NewProc("GlobalUnlock")
|
||||
// Allocates the specified number of bytes from the heap.
|
||||
// https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-globalalloc
|
||||
gAlloc = kernel32.NewProc("GlobalAlloc")
|
||||
// Frees the specified global memory object and invalidates its handle.
|
||||
// https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-globalfree
|
||||
gFree = kernel32.NewProc("GlobalFree")
|
||||
memMove = kernel32.NewProc("RtlMoveMemory")
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2021 The golang.design Initiative Authors.
|
||||
All rights reserved. Use of this source code is governed
|
||||
by a MIT license that can be found in the LICENSE file.
|
||||
|
||||
Written by Changkun Ou <changkun.de>
|
||||
-->
|
||||
<manifest
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="design.golang.clipboard.gclip"
|
||||
android:versionCode="1"
|
||||
android:versionName="1.0">
|
||||
|
||||
|
||||
<!-- In order to access the clipboard, the application manifest must
|
||||
specify the permission requirement. See the following page for
|
||||
details.
|
||||
http://developer.android.com/guide/topics/manifest/manifest-intro.html#perms -->
|
||||
<uses-permission android:name="android.permission.CLIPBOARD" />
|
||||
|
||||
<application android:label="gclip" android:debuggable="true">
|
||||
<activity android:name="org.golang.app.GoNativeActivity"
|
||||
android:label="Gclip"
|
||||
android:configChanges="orientation|keyboardHidden">
|
||||
<meta-data android:name="android.app.lib_name" android:value="Gclip" />
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,31 @@
|
||||
# gclip-gui
|
||||
|
||||
This is a very basic example for verification purpose that demonstrates
|
||||
how the [golang.design/x/clipboard](https://golang.design/x/clipboard)
|
||||
can interact with macOS/Linux/Windows/Android/iOS system clipboard.
|
||||
|
||||
The gclip GUI application writes a string to the system clipboard
|
||||
periodically then reads it back and renders it if possible.
|
||||
|
||||
Because of the system limitation, on mobile devices, only string data is
|
||||
supported at the moment. Hence, one must use clipboard.FmtText. Other supplied
|
||||
formats result in a panic.
|
||||
|
||||
This example is intentded as cross platform application. To build it, one
|
||||
must use [gomobile](https://golang.org/x/mobile). You may follow the instructions
|
||||
provided in the [GoMobile wiki](https://github.com/golang/go/wiki/Mobile) page.
|
||||
|
||||
|
||||
- For desktop: `go build -o gclip-gui`
|
||||
- For Android: `gomobile build -v -target=android -o gclip-gui.apk`
|
||||
- For iOS: `gomobile build -v -target=ios -bundleid design.golang.gclip-gui.app`
|
||||
|
||||
## Screenshots
|
||||
|
||||
| macOS | iOS | Windows | Android | Linux |
|
||||
|:-----:|:---:|:-------:|:-------:|:-----:|
|
||||
||||||
|
||||
|
||||
## License
|
||||
|
||||
MIT | © 2021 The golang.design Initiative Authors, written by [Changkun Ou](https://changkun.de).
|
||||
@@ -0,0 +1,236 @@
|
||||
// Copyright 2021 The golang.design Initiative Authors.
|
||||
// All rights reserved. Use of this source code is governed
|
||||
// by a MIT license that can be found in the LICENSE file.
|
||||
//
|
||||
// Written by Changkun Ou <changkun.de>
|
||||
|
||||
//go:build android || ios || linux || darwin || windows
|
||||
|
||||
// This is a very basic example for verification purpose that
|
||||
// demonstrates how the golang.design/x/clipboard can interact
|
||||
// with macOS/Linux/Windows/Android/iOS system clipboard.
|
||||
//
|
||||
// The gclip GUI application writes a string to the system clipboard
|
||||
// periodically then reads it back and renders it if possible.
|
||||
//
|
||||
// Because of the system limitation, on mobile devices, only string
|
||||
// data is supported at the moment. Hence, one must use clipboard.FmtText.
|
||||
// Other supplied formats result in a panic.
|
||||
//
|
||||
// This example is intentded as cross platform application.
|
||||
// To build it, one must use gomobile (https://golang.org/x/mobile).
|
||||
// You may follow the instructions provided in the GoMobile's wiki page:
|
||||
// https://github.com/golang/go/wiki/Mobile.
|
||||
//
|
||||
// - For desktop:
|
||||
//
|
||||
// go build -o gclip-gui
|
||||
//
|
||||
// - For Android:
|
||||
//
|
||||
// gomobile build -v -target=android -o gclip-gui.apk
|
||||
//
|
||||
// - For iOS:
|
||||
//
|
||||
// gomobile build -v -target=ios -bundleid design.golang.gclip-gui.app
|
||||
//
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"log"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.design/x/clipboard"
|
||||
|
||||
"golang.org/x/image/font"
|
||||
"golang.org/x/image/font/basicfont"
|
||||
"golang.org/x/image/math/fixed"
|
||||
"golang.org/x/mobile/app"
|
||||
"golang.org/x/mobile/event/lifecycle"
|
||||
"golang.org/x/mobile/event/paint"
|
||||
"golang.org/x/mobile/event/size"
|
||||
"golang.org/x/mobile/exp/gl/glutil"
|
||||
"golang.org/x/mobile/geom"
|
||||
"golang.org/x/mobile/gl"
|
||||
)
|
||||
|
||||
type Label struct {
|
||||
sz size.Event
|
||||
images *glutil.Images
|
||||
m *glutil.Image
|
||||
drawer *font.Drawer
|
||||
|
||||
mu sync.Mutex
|
||||
data string
|
||||
}
|
||||
|
||||
func NewLabel(images *glutil.Images) *Label {
|
||||
return &Label{
|
||||
images: images,
|
||||
data: "Hello! Gclip.",
|
||||
drawer: nil,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Label) SetLabel(s string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
l.data = s
|
||||
}
|
||||
|
||||
const (
|
||||
lineWidth = 100
|
||||
lineHeight = 120
|
||||
)
|
||||
|
||||
func (l *Label) Draw(sz size.Event) {
|
||||
l.mu.Lock()
|
||||
s := l.data
|
||||
l.mu.Unlock()
|
||||
imgW, imgH := lineWidth*basicfont.Face7x13.Width, lineHeight*basicfont.Face7x13.Height
|
||||
if sz.WidthPx == 0 && sz.HeightPx == 0 {
|
||||
return
|
||||
}
|
||||
if imgW > sz.WidthPx {
|
||||
imgW = sz.WidthPx
|
||||
}
|
||||
|
||||
if l.sz != sz {
|
||||
l.sz = sz
|
||||
if l.m != nil {
|
||||
l.m.Release()
|
||||
}
|
||||
l.m = l.images.NewImage(imgW, imgH)
|
||||
}
|
||||
// Clear the drawing image.
|
||||
for i := 0; i < len(l.m.RGBA.Pix); i++ {
|
||||
l.m.RGBA.Pix[i] = 0
|
||||
}
|
||||
|
||||
l.drawer = &font.Drawer{
|
||||
Dst: l.m.RGBA,
|
||||
Src: image.NewUniform(color.RGBA{0, 100, 125, 255}),
|
||||
Face: basicfont.Face7x13,
|
||||
Dot: fixed.P(5, 10),
|
||||
}
|
||||
l.drawer.DrawString(s)
|
||||
l.m.Upload()
|
||||
l.m.Draw(
|
||||
sz,
|
||||
geom.Point{X: 0, Y: 50},
|
||||
geom.Point{X: geom.Pt(imgW), Y: 50},
|
||||
geom.Point{X: 0, Y: geom.Pt(imgH)},
|
||||
l.m.RGBA.Bounds(),
|
||||
)
|
||||
}
|
||||
|
||||
func (l *Label) Release() {
|
||||
if l.m != nil {
|
||||
l.m.Release()
|
||||
l.m = nil
|
||||
l.images = nil
|
||||
}
|
||||
}
|
||||
|
||||
// GclipApp is the application instance.
|
||||
type GclipApp struct {
|
||||
app app.App
|
||||
|
||||
ctx gl.Context
|
||||
siz size.Event
|
||||
|
||||
images *glutil.Images
|
||||
l *Label
|
||||
|
||||
counter int
|
||||
}
|
||||
|
||||
// WatchClipboard watches the system clipboard every seconds.
|
||||
func (g *GclipApp) WatchClipboard() {
|
||||
go func() {
|
||||
tk := time.NewTicker(time.Second)
|
||||
for range tk.C {
|
||||
// Write something to the clipboard
|
||||
w := fmt.Sprintf("(gclip: %d)", g.counter)
|
||||
clipboard.Write(clipboard.FmtText, []byte(w))
|
||||
g.counter++
|
||||
log.Println(w)
|
||||
|
||||
// Read it back and render it, if possible.
|
||||
data := clipboard.Read(clipboard.FmtText)
|
||||
if len(data) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Set the current clipboard data as label content and render on the screen.
|
||||
r := fmt.Sprintf("clipboard: %s", string(data))
|
||||
g.l.SetLabel(r)
|
||||
g.app.Send(paint.Event{})
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (g *GclipApp) OnStart(e lifecycle.Event) {
|
||||
g.ctx, _ = e.DrawContext.(gl.Context)
|
||||
g.images = glutil.NewImages(g.ctx)
|
||||
g.l = NewLabel(g.images)
|
||||
g.app.Send(paint.Event{})
|
||||
}
|
||||
|
||||
func (g *GclipApp) OnStop() {
|
||||
g.l.Release()
|
||||
g.images.Release()
|
||||
g.ctx = nil
|
||||
}
|
||||
|
||||
func (g *GclipApp) OnSize(size size.Event) {
|
||||
g.siz = size
|
||||
}
|
||||
|
||||
func (g *GclipApp) OnDraw() {
|
||||
if g.ctx == nil {
|
||||
return
|
||||
}
|
||||
defer g.app.Send(paint.Event{})
|
||||
defer g.app.Publish()
|
||||
g.ctx.ClearColor(0, 0, 0, 1)
|
||||
g.ctx.Clear(gl.COLOR_BUFFER_BIT)
|
||||
g.l.Draw(g.siz)
|
||||
}
|
||||
|
||||
func init() {
|
||||
err := clipboard.Init()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
app.Main(func(a app.App) {
|
||||
gclip := GclipApp{app: a}
|
||||
gclip.app.Send(size.Event{WidthPx: 800, HeightPx: 500})
|
||||
gclip.WatchClipboard()
|
||||
for e := range gclip.app.Events() {
|
||||
switch e := gclip.app.Filter(e).(type) {
|
||||
case lifecycle.Event:
|
||||
switch e.Crosses(lifecycle.StageVisible) {
|
||||
case lifecycle.CrossOn:
|
||||
gclip.OnStart(e)
|
||||
case lifecycle.CrossOff:
|
||||
gclip.OnStop()
|
||||
os.Exit(0)
|
||||
}
|
||||
case size.Event:
|
||||
gclip.OnSize(e)
|
||||
case paint.Event:
|
||||
gclip.OnDraw()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
# gclip
|
||||
|
||||
`gclip` command offers the ability to interact with the system clipboard
|
||||
from the shell. To install:
|
||||
|
||||
```bash
|
||||
$ go install golang.design/x/clipboard/cmd/gclip@latest
|
||||
```
|
||||
|
||||
```bash
|
||||
$ gclip
|
||||
gclip is a command that provides clipboard interaction.
|
||||
usage: gclip [-copy|-paste] [-f <file>]
|
||||
options:
|
||||
-copy
|
||||
copy data to clipboard
|
||||
-f string
|
||||
source or destination to a given file path
|
||||
-paste
|
||||
paste data from clipboard
|
||||
examples:
|
||||
gclip -paste paste from clipboard and prints the content
|
||||
gclip -paste -f x.txt paste from clipboard and save as text to x.txt
|
||||
gclip -paste -f x.png paste from clipboard and save as image to x.png
|
||||
cat x.txt | gclip -copy copy content from x.txt to clipboard
|
||||
gclip -copy -f x.txt copy content from x.txt to clipboard
|
||||
gclip -copy -f x.png copy x.png as image data to clipboard
|
||||
```
|
||||
|
||||
If `-copy` is used, the command will exit when the data is no longer
|
||||
available from the clipboard. You can always send the command to the
|
||||
background using a shell `&` operator, for example:
|
||||
|
||||
```bash
|
||||
$ cat x.txt | gclip -copy &
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT | © 2021 The golang.design Initiative Authors, written by [Changkun Ou](https://changkun.de).
|
||||
@@ -0,0 +1,131 @@
|
||||
// Copyright 2021 The golang.design Initiative Authors.
|
||||
// All rights reserved. Use of this source code is governed
|
||||
// by a MIT license that can be found in the LICENSE file.
|
||||
//
|
||||
// Written by Changkun Ou <changkun.de>
|
||||
|
||||
package main // go install golang.design/x/clipboard/cmd/gclip@latest
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"golang.design/x/clipboard"
|
||||
)
|
||||
|
||||
func usage() {
|
||||
fmt.Fprintf(os.Stderr, `gclip is a command that provides clipboard interaction.
|
||||
|
||||
usage: gclip [-copy|-paste] [-f <file>]
|
||||
|
||||
options:
|
||||
`)
|
||||
flag.PrintDefaults()
|
||||
fmt.Fprintf(os.Stderr, `
|
||||
examples:
|
||||
gclip -paste paste from clipboard and prints the content
|
||||
gclip -paste -f x.txt paste from clipboard and save as text to x.txt
|
||||
gclip -paste -f x.png paste from clipboard and save as image to x.png
|
||||
|
||||
cat x.txt | gclip -copy copy content from x.txt to clipboard
|
||||
gclip -copy -f x.txt copy content from x.txt to clipboard
|
||||
gclip -copy -f x.png copy x.png as image data to clipboard
|
||||
`)
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
var (
|
||||
in = flag.Bool("copy", false, "copy data to clipboard")
|
||||
out = flag.Bool("paste", false, "paste data from clipboard")
|
||||
file = flag.String("f", "", "source or destination to a given file path")
|
||||
)
|
||||
|
||||
func init() {
|
||||
err := clipboard.Init()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
flag.Usage = usage
|
||||
flag.Parse()
|
||||
if *out {
|
||||
if err := pst(); err != nil {
|
||||
usage()
|
||||
}
|
||||
return
|
||||
}
|
||||
if *in {
|
||||
if err := cpy(); err != nil {
|
||||
usage()
|
||||
}
|
||||
return
|
||||
}
|
||||
usage()
|
||||
}
|
||||
|
||||
func cpy() error {
|
||||
t := clipboard.FmtText
|
||||
ext := filepath.Ext(*file)
|
||||
|
||||
switch ext {
|
||||
case ".png":
|
||||
t = clipboard.FmtImage
|
||||
case ".txt":
|
||||
fallthrough
|
||||
default:
|
||||
t = clipboard.FmtText
|
||||
}
|
||||
|
||||
var (
|
||||
b []byte
|
||||
err error
|
||||
)
|
||||
if *file != "" {
|
||||
b, err = os.ReadFile(*file)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to read given file: %v", err)
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
b, err = io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to read from stdin: %v", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Wait until clipboard content has been changed.
|
||||
<-clipboard.Write(t, b)
|
||||
return nil
|
||||
}
|
||||
|
||||
func pst() (err error) {
|
||||
var b []byte
|
||||
|
||||
b = clipboard.Read(clipboard.FmtText)
|
||||
if b == nil {
|
||||
b = clipboard.Read(clipboard.FmtImage)
|
||||
}
|
||||
|
||||
if *file != "" && b != nil {
|
||||
err = os.WriteFile(*file, b, os.ModePerm)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to write data to file %s: %v", *file, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
for len(b) > 0 {
|
||||
n, err := os.Stdout.Write(b)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b = b[n:]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Copyright 2021 The golang.design Initiative Authors.
|
||||
// All rights reserved. Use of this source code is governed
|
||||
// by a MIT license that can be found in the LICENSE file.
|
||||
//
|
||||
// Written by Changkun Ou <changkun.de>
|
||||
|
||||
//go:build cgo
|
||||
|
||||
package clipboard_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"golang.design/x/clipboard"
|
||||
)
|
||||
|
||||
func ExampleWrite() {
|
||||
err := clipboard.Init()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
clipboard.Write(clipboard.FmtText, []byte("Hello, 世界"))
|
||||
// Output:
|
||||
}
|
||||
|
||||
func ExampleRead() {
|
||||
err := clipboard.Init()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fmt.Println(string(clipboard.Read(clipboard.FmtText)))
|
||||
// Output:
|
||||
// Hello, 世界
|
||||
}
|
||||
|
||||
func ExampleWatch() {
|
||||
err := clipboard.Init()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*2)
|
||||
defer cancel()
|
||||
|
||||
changed := clipboard.Watch(context.Background(), clipboard.FmtText)
|
||||
go func(ctx context.Context) {
|
||||
clipboard.Write(clipboard.FmtText, []byte("你好,world"))
|
||||
}(ctx)
|
||||
fmt.Println(string(<-changed))
|
||||
// Output:
|
||||
// 你好,world
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright 2021 The golang.design Initiative Authors.
|
||||
// All rights reserved. Use of this source code is governed
|
||||
// by a MIT license that can be found in the LICENSE file.
|
||||
//
|
||||
// Written by Changkun Ou <changkun.de>
|
||||
|
||||
package clipboard
|
||||
|
||||
// for debugging errors
|
||||
var (
|
||||
Debug = debug
|
||||
ErrUnavailable = errUnavailable
|
||||
ErrCgoDisabled = errNoCgo
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
module golang.design/x/clipboard
|
||||
|
||||
go 1.24
|
||||
|
||||
require (
|
||||
golang.org/x/image v0.28.0
|
||||
golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f
|
||||
)
|
||||
|
||||
require (
|
||||
golang.org/x/exp/shiny v0.0.0-20250606033433-dcc06ee1d476 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
golang.org/x/exp/shiny v0.0.0-20250606033433-dcc06ee1d476 h1:Wdx0vgH5Wgsw+lF//LJKmWOJBLWX6nprsMqnf99rYDE=
|
||||
golang.org/x/exp/shiny v0.0.0-20250606033433-dcc06ee1d476/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8=
|
||||
golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE=
|
||||
golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY=
|
||||
golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f h1:/n+PL2HlfqeSiDCuhdBbRNlGS/g2fM4OHufalHaTVG8=
|
||||
golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f/go.mod h1:ESkJ836Z6LpG6mTVAhA48LpfW/8fNR0ifStlH2axyfg=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
Executable
+109
@@ -0,0 +1,109 @@
|
||||
#!/bin/bash
|
||||
# Test script for Linux CGO-free clipboard implementation
|
||||
|
||||
echo "Testing Linux clipboard implementation without CGO..."
|
||||
|
||||
# Check for required tools
|
||||
echo "Checking for clipboard tools..."
|
||||
for tool in xclip xsel wl-copy; do
|
||||
if command -v $tool &> /dev/null; then
|
||||
echo "✓ $tool is installed"
|
||||
else
|
||||
echo "✗ $tool is not installed"
|
||||
fi
|
||||
done
|
||||
|
||||
# Create test program
|
||||
cat > test_linux_clipboard.go << 'EOF'
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"golang.design/x/clipboard"
|
||||
)
|
||||
|
||||
func main() {
|
||||
err := clipboard.Init()
|
||||
if err != nil {
|
||||
log.Fatal("Failed to initialize clipboard:", err)
|
||||
}
|
||||
|
||||
// Test text
|
||||
fmt.Println("\n=== Testing Text Clipboard ===")
|
||||
testText := []byte("Hello from CGO-free Linux clipboard!")
|
||||
clipboard.Write(clipboard.FmtText, testText)
|
||||
fmt.Println("Wrote text:", string(testText))
|
||||
|
||||
readText := clipboard.Read(clipboard.FmtText)
|
||||
fmt.Println("Read text:", string(readText))
|
||||
|
||||
if string(testText) == string(readText) {
|
||||
fmt.Println("✓ Text clipboard test passed")
|
||||
} else {
|
||||
fmt.Println("✗ Text clipboard test failed")
|
||||
}
|
||||
|
||||
// Test empty write
|
||||
fmt.Println("\n=== Testing Empty Write ===")
|
||||
clipboard.Write(clipboard.FmtText, []byte{})
|
||||
emptyRead := clipboard.Read(clipboard.FmtText)
|
||||
if emptyRead == nil || len(emptyRead) == 0 {
|
||||
fmt.Println("✓ Empty write test passed")
|
||||
} else {
|
||||
fmt.Println("✗ Empty write test failed, got:", string(emptyRead))
|
||||
}
|
||||
|
||||
// Test image if requested
|
||||
if len(os.Args) > 1 && os.Args[1] == "image" {
|
||||
fmt.Println("\n=== Testing Image Clipboard ===")
|
||||
|
||||
// Try to read test image
|
||||
imageData, err := os.ReadFile("tests/testdata/clipboard.png")
|
||||
if err != nil {
|
||||
fmt.Println("Could not read test image:", err)
|
||||
return
|
||||
}
|
||||
|
||||
clipboard.Write(clipboard.FmtImage, imageData)
|
||||
fmt.Println("Wrote image data, length:", len(imageData))
|
||||
|
||||
readImage := clipboard.Read(clipboard.FmtImage)
|
||||
if readImage != nil {
|
||||
fmt.Println("Read image data, length:", len(readImage))
|
||||
if len(imageData) == len(readImage) {
|
||||
fmt.Println("✓ Image clipboard test passed")
|
||||
} else {
|
||||
fmt.Println("✗ Image lengths don't match")
|
||||
}
|
||||
} else {
|
||||
fmt.Println("✗ Failed to read image from clipboard")
|
||||
}
|
||||
|
||||
// Test that reading text from image clipboard returns nil
|
||||
textFromImage := clipboard.Read(clipboard.FmtText)
|
||||
if textFromImage == nil {
|
||||
fmt.Println("✓ Reading text from image clipboard correctly returned nil")
|
||||
} else {
|
||||
fmt.Println("✗ Reading text from image clipboard should return nil, got:", string(textFromImage))
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# Run tests with CGO disabled
|
||||
echo -e "\n=== Running with CGO_ENABLED=0 ==="
|
||||
CGO_ENABLED=0 go run test_linux_clipboard.go
|
||||
|
||||
echo -e "\n=== Running with CGO_ENABLED=0 and image test ==="
|
||||
CGO_ENABLED=0 go run test_linux_clipboard.go image
|
||||
|
||||
# Run actual tests
|
||||
echo -e "\n=== Running go test with CGO_ENABLED=0 ==="
|
||||
CGO_ENABLED=0 go test -v -run TestClipboard
|
||||
|
||||
# Clean up
|
||||
rm -f test_linux_clipboard.go
|
||||
|
||||
echo -e "\nTest script completed!"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user