Compare commits

..
Author SHA1 Message Date
opencode 37f89b7429 release: v1.15.3 2026-05-16 14:07:22 +00:00
78 changed files with 783 additions and 3671 deletions
+12
View File
@@ -124,6 +124,18 @@ XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
اذا كنت تعمل على مشروع مرتبط بـ OpenCode ويستخدم "opencode" كجزء من اسمه (مثل "opencode-dashboard" او "opencode-mobile")، يرجى اضافة ملاحظة في README توضح انه ليس مبنيا بواسطة فريق OpenCode ولا يرتبط بنا بأي شكل.
### FAQ
#### ما الفرق عن Claude Code؟
هو مشابه جدا لـ Claude Code من حيث القدرات. هذه هي الفروقات الاساسية:
- 100% مفتوح المصدر
- غير مقترن بمزود معين. نوصي بالنماذج التي نوفرها عبر [OpenCode Zen](https://opencode.ai/zen)؛ لكن يمكن استخدام OpenCode مع Claude او OpenAI او Google او حتى نماذج محلية. مع تطور النماذج ستتقلص الفجوات وستنخفض الاسعار، لذا من المهم ان يكون مستقلا عن المزود.
- دعم LSP جاهز للاستخدام
- تركيز على TUI. تم بناء OpenCode بواسطة مستخدمي neovim ومنشئي [terminal.shop](https://terminal.shop)؛ وسندفع حدود ما هو ممكن داخل الطرفية.
- معمارية عميل/خادم. على سبيل المثال، يمكن تشغيل OpenCode على جهازك بينما تقوده عن بعد من تطبيق جوال. هذا يعني ان واجهة TUI هي واحدة فقط من العملاء الممكنين.
---
**انضم الى مجتمعنا** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
+12
View File
@@ -124,6 +124,18 @@ OpenCode এ দুটি বিল্ট-ইন এজেন্ট রয়ে
আপনি যদি এমন প্রজেক্টে কাজ করেন যা OpenCode এর সাথে সম্পর্কিত এবং প্রজেক্টের নামের অংশ হিসেবে "opencode" ব্যবহার করেন, উদাহরণস্বরূপ "opencode-dashboard" বা "opencode-mobile", তবে দয়া করে আপনার README তে একটি নোট যোগ করে স্পষ্ট করুন যে এই প্রজেক্টটি OpenCode দল দ্বারা তৈরি হয়নি এবং আমাদের সাথে এর কোনো সরাসরি সম্পর্ক নেই।
### সচরাচর জিজ্ঞাসিত প্রশ্নাবলী (FAQ)
#### এটি ক্লড কোড (Claude Code) থেকে কীভাবে আলাদা?
ক্যাপাবিলিটির দিক থেকে এটি ক্লড কোডের (Claude Code) মতই। এখানে মূল পার্থক্যগুলো দেওয়া হলো:
- ১০০% ওপেন সোর্স
- কোনো প্রোভাইডারের সাথে আবদ্ধ নয়। যদিও আমরা [OpenCode Zen](https://opencode.ai/zen) এর মাধ্যমে মডেলসমূহ ব্যবহারের পরামর্শ দিই, OpenCode ক্লড (Claude), ওপেনএআই (OpenAI), গুগল (Google), অথবা লোকাল মডেলগুলোর সাথেও ব্যবহার করা যেতে পারে। যেমন যেমন মডেলগুলো উন্নত হবে, তাদের মধ্যকার পার্থক্য কমে আসবে এবং দামও কমবে, তাই প্রোভাইডার-অজ্ঞাস্টিক হওয়া খুবই গুরুত্বপূর্ণ।
- আউট-অফ-দ্য-বক্স LSP সাপোর্ট
- TUI এর উপর ফোকাস। OpenCode নিওভিম (neovim) ব্যবহারকারী এবং [terminal.shop](https://terminal.shop) এর নির্মাতাদের দ্বারা তৈরি; আমরা টার্মিনালে কী কী সম্ভব তার সীমাবদ্ধতা ছাড়িয়ে যাওয়ার চেষ্টা করছি।
- ক্লায়েন্ট/সার্ভার আর্কিটেকচার। এটি যেমন OpenCode কে আপনার কম্পিউটারে চালানোর সুযোগ দেয়, তেমনি আপনি মোবাইল অ্যাপ থেকে রিমোটলি এটি নিয়ন্ত্রণ করতে পারবেন, অর্থাৎ TUI ফ্রন্টএন্ড কেবল সম্ভাব্য ক্লায়েন্টগুলোর মধ্যে একটি।
---
**আমাদের কমিউনিটিতে যুক্ত হোন** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
+12
View File
@@ -124,6 +124,18 @@ Se você tem interesse em contribuir com o OpenCode, leia os [contributing docs]
Se você estiver trabalhando em um projeto relacionado ao OpenCode e estiver usando "opencode" como parte do nome (por exemplo, "opencode-dashboard" ou "opencode-mobile"), adicione uma nota no README para deixar claro que não foi construído pela equipe do OpenCode e não é afiliado a nós de nenhuma forma.
### FAQ
#### Como isso é diferente do Claude Code?
É muito parecido com o Claude Code em termos de capacidade. Aqui estão as principais diferenças:
- 100% open source
- Não está acoplado a nenhum provedor. Embora recomendemos os modelos que oferecemos pelo [OpenCode Zen](https://opencode.ai/zen); o OpenCode pode ser usado com Claude, OpenAI, Google ou até modelos locais. À medida que os modelos evoluem, as diferenças diminuem e os preços caem, então ser provider-agnostic é importante.
- Suporte a LSP pronto para uso
- Foco em TUI. O OpenCode é construído por usuários de neovim e pelos criadores do [terminal.shop](https://terminal.shop); vamos levar ao limite o que é possível no terminal.
- Arquitetura cliente/servidor. Isso, por exemplo, permite executar o OpenCode no seu computador enquanto você o controla remotamente por um aplicativo mobile. Isso significa que o frontend TUI é apenas um dos possíveis clientes.
---
**Junte-se à nossa comunidade** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
+12
View File
@@ -124,6 +124,18 @@ Ako želiš doprinositi OpenCode-u, pročitaj [upute za doprinošenje](./CONTRIB
Ako radiš na projektu koji je povezan s OpenCode-om i koristi "opencode" kao dio naziva, npr. "opencode-dashboard" ili "opencode-mobile", dodaj napomenu u svoj README da projekat nije napravio OpenCode tim i da nije povezan s nama.
### FAQ
#### Po čemu se razlikuje od Claude Code-a?
Po mogućnostima je vrlo sličan Claude Code-u. Ključne razlike su:
- 100% open source
- Nije vezan za jednog provajdera. Iako preporučujemo modele koje nudimo kroz [OpenCode Zen](https://opencode.ai/zen), OpenCode možeš koristiti s Claude, OpenAI, Google ili čak lokalnim modelima. Kako modeli napreduju, razlike među njima će se smanjivati, a cijene padati, zato je nezavisnost od provajdera važna.
- LSP podrška odmah po instalaciji
- Fokus na TUI. OpenCode grade neovim korisnici i kreatori [terminal.shop](https://terminal.shop); pomjeraćemo granice onoga što je moguće u terminalu.
- Klijent/server arhitektura. To, recimo, omogućava da OpenCode radi na tvom računaru dok ga daljinski koristiš iz mobilne aplikacije, što znači da je TUI frontend samo jedan od mogućih klijenata.
---
**Pridruži se našoj zajednici** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
+12
View File
@@ -124,6 +124,18 @@ Hvis du vil bidrage til OpenCode, så læs vores [contributing docs](./CONTRIBUT
Hvis du arbejder på et projekt der er relateret til OpenCode og bruger "opencode" som en del af navnet; f.eks. "opencode-dashboard" eller "opencode-mobile", så tilføj en note i din README, der tydeliggør at projektet ikke er bygget af OpenCode-teamet og ikke er tilknyttet os på nogen måde.
### FAQ
#### Hvordan adskiller dette sig fra Claude Code?
Det minder meget om Claude Code i forhold til funktionalitet. Her er de vigtigste forskelle:
- 100% open source
- Ikke låst til en udbyder. Selvom vi anbefaler modellerne via [OpenCode Zen](https://opencode.ai/zen); kan OpenCode bruges med Claude, OpenAI, Google eller endda lokale modeller. Efterhånden som modeller udvikler sig vil forskellene mindskes og priserne falde, så det er vigtigt at være provider-agnostic.
- LSP-support out of the box
- Fokus på TUI. OpenCode er bygget af neovim-brugere og skaberne af [terminal.shop](https://terminal.shop); vi vil skubbe grænserne for hvad der er muligt i terminalen.
- Klient/server-arkitektur. Det kan f.eks. lade OpenCode køre på din computer, mens du styrer den eksternt fra en mobilapp. Det betyder at TUI-frontend'en kun er en af de mulige clients.
---
**Bliv en del af vores community** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
+12
View File
@@ -124,6 +124,18 @@ Wenn du zu OpenCode beitragen möchtest, lies bitte unsere [Contributing Docs](.
Wenn du an einem Projekt arbeitest, das mit OpenCode zusammenhängt und "opencode" als Teil seines Namens verwendet (z.B. "opencode-dashboard" oder "opencode-mobile"), füge bitte einen Hinweis in deine README ein, dass es nicht vom OpenCode-Team gebaut wird und nicht in irgendeiner Weise mit uns verbunden ist.
### FAQ
#### Worin unterscheidet sich das von Claude Code?
In Bezug auf die Fähigkeiten ist es Claude Code sehr ähnlich. Hier sind die wichtigsten Unterschiede:
- 100% open source
- Nicht an einen Anbieter gekoppelt. Wir empfehlen die Modelle aus [OpenCode Zen](https://opencode.ai/zen); OpenCode kann aber auch mit Claude, OpenAI, Google oder sogar lokalen Modellen genutzt werden. Mit der Weiterentwicklung der Modelle werden die Unterschiede kleiner und die Preise sinken, deshalb ist Provider-Unabhängigkeit wichtig.
- LSP-Unterstützung direkt nach dem Start
- Fokus auf TUI. OpenCode wird von Neovim-Nutzern und den Machern von [terminal.shop](https://terminal.shop) gebaut; wir treiben die Grenzen dessen, was im Terminal möglich ist.
- Client/Server-Architektur. Das ermöglicht z.B., OpenCode auf deinem Computer laufen zu lassen, während du es von einer mobilen App aus fernsteuerst. Das TUI-Frontend ist nur einer der möglichen Clients.
---
**Tritt unserer Community bei** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
+21 -9
View File
@@ -97,20 +97,20 @@ OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bas
XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
```
### Agentes
### Agents
OpenCode incluye dos agentes integrados que puedes alternar con la tecla `Tab`.
OpenCode incluye dos agents integrados que puedes alternar con la tecla `Tab`.
- **build** - Por defecto, agente con acceso completo para tareas de desarrollo
- **plan** - Agente de solo lectura para análisis y exploración de código
- Deniega ediciones de archivos por defecto
- **build** - Por defecto, agent con acceso completo para trabajo de desarrollo
- **plan** - Agent de solo lectura para análisis y exploración de código
- Niega ediciones de archivos por defecto
- Pide permiso antes de ejecutar comandos bash
- Ideal para explorar codebases desconocidas o planificar cambios
Además, incluye un subagente **general** para búsquedas complejas y tareas de varios pasos.
Además, incluye un subagent **general** para búsquedas complejas y tareas de varios pasos.
Se usa internamente y se puede invocar con `@general` en los mensajes.
Más información sobre [agentes](https://opencode.ai/docs/agents).
Más información sobre [agents](https://opencode.ai/docs/agents).
### Documentación
@@ -120,9 +120,21 @@ Para más información sobre cómo configurar OpenCode, [**ve a nuestra document
Si te interesa contribuir a OpenCode, lee nuestras [docs de contribución](./CONTRIBUTING.md) antes de enviar un pull request.
### Proyectos basados en OpenCode
### Construyendo sobre OpenCode
Si estás trabajando en un proyecto basado en OpenCode y usas "opencode" como parte del nombre, por ejemplo, "opencode-dashboard" u "opencode-mobile", agrega una nota en tu README para aclarar que no está hecho por el equipo de OpenCode y que no está afiliado con nosotros de ninguna manera.
Si estás trabajando en un proyecto relacionado con OpenCode y usas "opencode" como parte del nombre; por ejemplo, "opencode-dashboard" u "opencode-mobile", agrega una nota en tu README para aclarar que no está construido por el equipo de OpenCode y que no está afiliado con nosotros de ninguna manera.
### FAQ
#### ¿En qué se diferencia de Claude Code?
Es muy similar a Claude Code en cuanto a capacidades. Estas son las diferencias clave:
- 100% open source
- No está acoplado a ningún proveedor. Aunque recomendamos los modelos que ofrecemos a través de [OpenCode Zen](https://opencode.ai/zen); OpenCode se puede usar con Claude, OpenAI, Google o incluso modelos locales. A medida que evolucionan los modelos, las brechas se cerrarán y los precios bajarán, por lo que ser agnóstico al proveedor es importante.
- Soporte LSP listo para usar
- Un enfoque en la TUI. OpenCode está construido por usuarios de neovim y los creadores de [terminal.shop](https://terminal.shop); vamos a empujar los límites de lo que es posible en la terminal.
- Arquitectura cliente/servidor. Esto, por ejemplo, permite ejecutar OpenCode en tu computadora mientras lo controlas de forma remota desde una app móvil. Esto significa que el frontend TUI es solo uno de los posibles clientes.
---
+12
View File
@@ -124,6 +124,18 @@ Si vous souhaitez contribuer à OpenCode, lisez nos [docs de contribution](./CON
Si vous travaillez sur un projet lié à OpenCode et que vous utilisez "opencode" dans le nom du projet (par exemple, "opencode-dashboard" ou "opencode-mobile"), ajoutez une note dans votre README pour préciser qu'il n'est pas construit par l'équipe OpenCode et qu'il n'est pas affilié à nous.
### FAQ
#### En quoi est-ce différent de Claude Code ?
C'est très similaire à Claude Code en termes de capacités. Voici les principales différences :
- 100% open source
- Pas couplé à un fournisseur. Nous recommandons les modèles proposés via [OpenCode Zen](https://opencode.ai/zen) ; OpenCode peut être utilisé avec Claude, OpenAI, Google ou même des modèles locaux. Au fur et à mesure que les modèles évoluent, les écarts se réduiront et les prix baisseront, donc être agnostique au fournisseur est important.
- Support LSP prêt à l'emploi
- Un focus sur la TUI. OpenCode est construit par des utilisateurs de neovim et les créateurs de [terminal.shop](https://terminal.shop) ; nous allons repousser les limites de ce qui est possible dans le terminal.
- Architecture client/serveur. Cela permet par exemple de faire tourner OpenCode sur votre ordinateur tout en le pilotant à distance depuis une application mobile. Cela signifie que la TUI n'est qu'un des clients possibles.
---
**Rejoignez notre communauté** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
+12
View File
@@ -124,6 +124,18 @@ XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
Εάν εργάζεσαι σε ένα έργο σχετικό με το OpenCode και χρησιμοποιείτε το "opencode" ως μέρος του ονόματός του, για παράδειγμα "opencode-dashboard" ή "opencode-mobile", πρόσθεσε μια σημείωση στο README σας για να διευκρινίσεις ότι δεν είναι κατασκευασμένο από την ομάδα του OpenCode και δεν έχει καμία σχέση με εμάς.
### Συχνές Ερωτήσεις
#### Πώς διαφέρει αυτό από το Claude Code;
Είναι πολύ παρόμοιο με το Claude Code ως προς τις δυνατότητες. Ακολουθούν οι βασικές διαφορές:
- 100% ανοιχτού κώδικα
- Δεν είναι συνδεδεμένο με κανέναν πάροχο. Αν και συνιστούμε τα μοντέλα που παρέχουμε μέσω του [OpenCode Zen](https://opencode.ai/zen), το OpenCode μπορεί να χρησιμοποιηθεί με Claude, OpenAI, Google, ή ακόμα και τοπικά μοντέλα. Καθώς τα μοντέλα εξελίσσονται, τα κενά μεταξύ τους θα κλείσουν και οι τιμές θα μειωθούν, οπότε είναι σημαντικό να είσαι ανεξάρτητος από τον πάροχο.
- Out-of-the-box υποστήριξη LSP
- Εστίαση στο TUI. Το OpenCode είναι κατασκευασμένο από χρήστες που χρησιμοποιούν neovim και τους δημιουργούς του [terminal.shop](https://terminal.shop)· θα εξαντλήσουμε τα όρια του τι είναι δυνατό στο terminal.
- Αρχιτεκτονική client/server. Αυτό, για παράδειγμα, μπορεί να επιτρέψει στο OpenCode να τρέχει στον υπολογιστή σου ενώ το χειρίζεσαι εξ αποστάσεως από μια εφαρμογή κινητού, που σημαίνει ότι το TUI frontend είναι μόνο ένας από τους πιθανούς clients.
---
**Γίνε μέλος της κοινότητάς μας** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
+12
View File
@@ -124,6 +124,18 @@ Se sei interessato a contribuire a OpenCode, leggi la nostra [guida alla contrib
Se stai lavorando a un progetto correlato a OpenCode e che utilizza “opencode” come parte del nome (ad esempio “opencode-dashboard” o “opencode-mobile”), aggiungi una nota nel tuo README per chiarire che non è sviluppato dal team OpenCode e che non è affiliato in alcun modo con noi.
### FAQ
#### In cosa è diverso da Claude Code?
È molto simile a Claude Code in termini di funzionalità. Ecco le principali differenze:
- 100% open source
- Non è legato a nessun provider. Anche se consigliamo i modelli forniti tramite [OpenCode Zen](https://opencode.ai/zen), OpenCode può essere utilizzato con Claude, OpenAI, Google o persino modelli locali. Con levoluzione dei modelli, le differenze tra di essi si ridurranno e i prezzi scenderanno, quindi essere indipendenti dal provider è importante.
- Supporto LSP pronto alluso
- Forte attenzione alla TUI. OpenCode è sviluppato da utenti neovim e dai creatori di [terminal.shop](https://terminal.shop); spingeremo al limite ciò che è possibile fare nel terminale.
- Architettura client/server. Questo, ad esempio, permette a OpenCode di girare sul tuo computer mentre lo controlli da remoto tramite unapp mobile. La frontend TUI è quindi solo uno dei possibili client.
---
**Unisciti alla nostra community** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
+12
View File
@@ -124,6 +124,18 @@ OpenCode に貢献したい場合は、Pull Request を送る前に [contributin
OpenCode に関連するプロジェクトで、名前に "opencode"(例: "opencode-dashboard" や "opencode-mobile")を含める場合は、そのプロジェクトが OpenCode チームによって作られたものではなく、いかなる形でも関係がないことを README に明記してください。
### FAQ
#### Claude Code との違いは?
機能面では Claude Code と非常に似ています。主な違いは次のとおりです。
- 100% オープンソース
- 特定のプロバイダーに依存しません。[OpenCode Zen](https://opencode.ai/zen) で提供しているモデルを推奨しますが、OpenCode は Claude、OpenAI、Google、またはローカルモデルでも利用できます。モデルが進化すると差は縮まり価格も下がるため、provider-agnostic であることが重要です。
- そのまま使える LSP サポート
- TUI にフォーカス。OpenCode は neovim ユーザーと [terminal.shop](https://terminal.shop) の制作者によって作られており、ターミナルで可能なことの限界を押し広げます。
- クライアント/サーバー構成。例えば OpenCode をあなたのPCで動かし、モバイルアプリからリモート操作できます。TUI フロントエンドは複数あるクライアントの1つにすぎません。
---
**コミュニティに参加** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
+12
View File
@@ -124,6 +124,18 @@ OpenCode 에 기여하고 싶다면, Pull Request 를 제출하기 전에 [contr
OpenCode 와 관련된 프로젝트를 진행하면서 이름에 "opencode"(예: "opencode-dashboard" 또는 "opencode-mobile") 를 포함한다면, README 에 해당 프로젝트가 OpenCode 팀이 만든 것이 아니며 어떤 방식으로도 우리와 제휴되어 있지 않다는 점을 명시해 주세요.
### FAQ
#### Claude Code 와는 무엇이 다른가요?
기능 면에서는 Claude Code 와 매우 유사합니다. 주요 차이점은 다음과 같습니다.
- 100% 오픈 소스
- 특정 제공자에 묶여 있지 않습니다. [OpenCode Zen](https://opencode.ai/zen) 을 통해 제공하는 모델을 권장하지만, OpenCode 는 Claude, OpenAI, Google 또는 로컬 모델과도 사용할 수 있습니다. 모델이 발전하면서 격차는 줄고 가격은 내려가므로 provider-agnostic 인 것이 중요합니다.
- 기본으로 제공되는 LSP 지원
- TUI 에 집중. OpenCode 는 neovim 사용자와 [terminal.shop](https://terminal.shop) 제작자가 만들었으며, 터미널에서 가능한 것의 한계를 밀어붙입니다.
- 클라이언트/서버 아키텍처. 예를 들어 OpenCode 를 내 컴퓨터에서 실행하면서 모바일 앱으로 원격 조작할 수 있습니다. 즉, TUI 프런트엔드는 가능한 여러 클라이언트 중 하나일 뿐입니다.
---
**커뮤니티에 참여하기** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
+12
View File
@@ -124,6 +124,18 @@ If you're interested in contributing to OpenCode, please read our [contributing
If you are working on a project that's related to OpenCode and is using "opencode" as part of its name, for example "opencode-dashboard" or "opencode-mobile", please add a note to your README to clarify that it is not built by the OpenCode team and is not affiliated with us in any way.
### FAQ
#### How is this different from Claude Code?
It's very similar to Claude Code in terms of capability. Here are the key differences:
- 100% open source
- Not coupled to any provider. Although we recommend the models we provide through [OpenCode Zen](https://opencode.ai/zen), OpenCode can be used with Claude, OpenAI, Google, or even local models. As models evolve, the gaps between them will close and pricing will drop, so being provider-agnostic is important.
- Built-in opt-in LSP support
- A focus on TUI. OpenCode is built by neovim users and the creators of [terminal.shop](https://terminal.shop); we are going to push the limits of what's possible in the terminal.
- A client/server architecture. This, for example, can allow OpenCode to run on your computer while you drive it remotely from a mobile app, meaning that the TUI frontend is just one of the possible clients.
---
**Join our community** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
+12
View File
@@ -124,6 +124,18 @@ Hvis du vil bidra til OpenCode, les [contributing docs](./CONTRIBUTING.md) før
Hvis du jobber med et prosjekt som er relatert til OpenCode og bruker "opencode" som en del av navnet; for eksempel "opencode-dashboard" eller "opencode-mobile", legg inn en merknad i README som presiserer at det ikke er bygget av OpenCode-teamet og ikke er tilknyttet oss på noen måte.
### FAQ
#### Hvordan er dette forskjellig fra Claude Code?
Det er veldig likt Claude Code når det gjelder funksjonalitet. Her er de viktigste forskjellene:
- 100% open source
- Ikke knyttet til en bestemt leverandør. Selv om vi anbefaler modellene vi tilbyr gjennom [OpenCode Zen](https://opencode.ai/zen); kan OpenCode brukes med Claude, OpenAI, Google eller til og med lokale modeller. Etter hvert som modellene utvikler seg vil gapene lukkes og prisene gå ned, så det er viktig å være provider-agnostic.
- LSP-støtte rett ut av boksen
- Fokus på TUI. OpenCode er bygget av neovim-brukere og skaperne av [terminal.shop](https://terminal.shop); vi kommer til å presse grensene for hva som er mulig i terminalen.
- Klient/server-arkitektur. Dette kan for eksempel la OpenCode kjøre på maskinen din, mens du styrer den eksternt fra en mobilapp. Det betyr at TUI-frontend'en bare er en av de mulige klientene.
---
**Bli med i fellesskapet** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
+12
View File
@@ -124,6 +124,18 @@ Jeśli chcesz współtworzyć OpenCode, przeczytaj [contributing docs](./CONTRIB
Jeśli pracujesz nad projektem związanym z OpenCode i używasz "opencode" jako części nazwy (na przykład "opencode-dashboard" lub "opencode-mobile"), dodaj proszę notatkę do swojego README, aby wyjaśnić, że projekt nie jest tworzony przez zespół OpenCode i nie jest z nami w żaden sposób powiązany.
### FAQ
#### Czym to się różni od Claude Code?
Jest bardzo podobne do Claude Code pod względem możliwości. Oto kluczowe różnice:
- 100% open source
- Niezależne od dostawcy. Chociaż polecamy modele oferowane przez [OpenCode Zen](https://opencode.ai/zen); OpenCode może być używany z Claude, OpenAI, Google, a nawet z modelami lokalnymi. W miarę jak modele ewoluują, różnice będą się zmniejszać, a ceny spadać, więc ważna jest niezależność od dostawcy.
- Wbudowane wsparcie LSP
- Skupienie na TUI. OpenCode jest budowany przez użytkowników neovim i twórców [terminal.shop](https://terminal.shop); przesuwamy granice tego, co jest możliwe w terminalu.
- Architektura klient/serwer. Pozwala np. uruchomić OpenCode na twoim komputerze, a sterować nim zdalnie z aplikacji mobilnej. To znaczy, że frontend TUI jest tylko jednym z możliwych klientów.
---
**Dołącz do naszej społeczności** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
+12
View File
@@ -124,6 +124,18 @@ XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
Если вы делаете проект, связанный с OpenCode, и используете "opencode" как часть имени (например, "opencode-dashboard" или "opencode-mobile"), добавьте примечание в README, чтобы уточнить, что проект не создан командой OpenCode и не аффилирован с нами.
### FAQ
#### Чем это отличается от Claude Code?
По возможностям это очень похоже на Claude Code. Вот ключевые отличия:
- 100% open source
- Не привязано к одному провайдеру. Мы рекомендуем модели из [OpenCode Zen](https://opencode.ai/zen); но OpenCode можно использовать с Claude, OpenAI, Google или даже локальными моделями. По мере развития моделей разрыв будет сокращаться, а цены падать, поэтому важна независимость от провайдера.
- Поддержка LSP из коробки
- Фокус на TUI. OpenCode построен пользователями neovim и создателями [terminal.shop](https://terminal.shop); мы будем раздвигать границы того, что возможно в терминале.
- Архитектура клиент/сервер. Например, это позволяет запускать OpenCode на вашем компьютере, а управлять им удаленно из мобильного приложения. Это значит, что TUI-фронтенд - лишь один из возможных клиентов.
---
**Присоединяйтесь к нашему сообществу** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
+12
View File
@@ -124,6 +124,18 @@ OpenCode รวมเอเจนต์ในตัวสองตัวที
หากคุณทำงานในโปรเจกต์ที่เกี่ยวข้องกับ OpenCode และใช้ "opencode" เป็นส่วนหนึ่งของชื่อ เช่น "opencode-dashboard" หรือ "opencode-mobile" โปรดเพิ่มหมายเหตุใน README ของคุณเพื่อชี้แจงว่าไม่ได้สร้างโดยทีม OpenCode และไม่ได้เกี่ยวข้องกับเราในทางใด
### คำถามที่พบบ่อย
#### ต่างจาก Claude Code อย่างไร?
คล้ายกับ Claude Code มากในแง่ความสามารถ นี่คือความแตกต่างหลัก:
- โอเพนซอร์ส 100%
- ไม่ผูกมัดกับผู้ให้บริการใดๆ แม้ว่าเราจะแนะนำโมเดลที่เราจัดหาให้ผ่าน [OpenCode Zen](https://opencode.ai/zen) OpenCode สามารถใช้กับ Claude, OpenAI, Google หรือแม้กระทั่งโมเดลในเครื่องได้ เมื่อโมเดลพัฒนาช่องว่างระหว่างพวกมันจะปิดลงและราคาจะลดลง ดังนั้นการไม่ผูกมัดกับผู้ให้บริการจึงสำคัญ
- รองรับ LSP ใช้งานได้ทันทีหลังการติดตั้งโดยไม่ต้องปรับแต่งหรือเปลี่ยนแปลงฟังก์ชันการทำงานใด ๆ
- เน้นที่ TUI OpenCode สร้างโดยผู้ใช้ neovim และผู้สร้าง [terminal.shop](https://terminal.shop) เราจะผลักดันขีดจำกัดของสิ่งที่เป็นไปได้ในเทอร์มินัล
- สถาปัตยกรรมไคลเอนต์/เซิร์ฟเวอร์ ตัวอย่างเช่น อาจอนุญาตให้ OpenCode ทำงานบนคอมพิวเตอร์ของคุณ ในขณะที่คุณสามารถขับเคลื่อนจากระยะไกลผ่านแอปมือถือ หมายความว่า TUI frontend เป็นหนึ่งในไคลเอนต์ที่เป็นไปได้เท่านั้น
---
**ร่วมชุมชนของเรา** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
+12
View File
@@ -124,6 +124,18 @@ OpenCode'a katkıda bulunmak istiyorsanız, lütfen bir pull request göndermede
OpenCode ile ilgili bir proje üzerinde çalışıyorsanız ve projenizin adının bir parçası olarak "opencode" kullanıyorsanız (örneğin, "opencode-dashboard" veya "opencode-mobile"), lütfen README dosyanıza projenin OpenCode ekibi tarafından geliştirilmediğini ve bizimle hiçbir şekilde bağlantılı olmadığını belirten bir not ekleyin.
### SSS
#### Bu Claude Code'dan nasıl farklı?
Yetenekler açısından Claude Code'a çok benzer. İşte temel farklar:
- %100 açık kaynak
- Herhangi bir sağlayıcıya bağlı değil. [OpenCode Zen](https://opencode.ai/zen) üzerinden sunduğumuz modelleri önermekle birlikte; OpenCode, Claude, OpenAI, Google veya hatta yerel modellerle kullanılabilir. Modeller geliştikçe aralarındaki farklar kapanacak ve fiyatlar düşecek, bu nedenle sağlayıcıdan bağımsız olmak önemlidir.
- Kurulum gerektirmeyen hazır LSP desteği
- TUI odaklı yaklaşım. OpenCode, neovim kullanıcıları ve [terminal.shop](https://terminal.shop)'un geliştiricileri tarafından geliştirilmektedir; terminalde olabileceklerin sınırlarını zorlayacağız.
- İstemci/sunucu (client/server) mimarisi. Bu, örneğin OpenCode'un bilgisayarınızda çalışması ve siz onu bir mobil uygulamadan uzaktan yönetmenizi sağlar. TUI arayüzü olası istemcilerden sadece biridir.
---
**Topluluğumuza katılın** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
+12
View File
@@ -125,6 +125,18 @@ OpenCode містить два вбудовані агенти, між яким
Якщо ви працюєте над проєктом, пов'язаним з OpenCode, і використовуєте "opencode" у назві, наприклад "opencode-dashboard" або "opencode-mobile", додайте примітку до свого README.
Уточніть, що цей проєкт не створений командою OpenCode і жодним чином не афілійований із нами.
### FAQ
#### Чим це відрізняється від Claude Code?
За можливостями це дуже схоже на Claude Code. Ось ключові відмінності:
- 100% open source
- Немає прив'язки до конкретного провайдера. Ми рекомендуємо моделі, які надаємо через [OpenCode Zen](https://opencode.ai/zen), але OpenCode також працює з Claude, OpenAI, Google і навіть локальними моделями. З розвитком моделей різниця між ними зменшуватиметься, а ціни падатимуть, тому незалежність від провайдера має значення.
- Підтримка LSP з коробки
- Фокус на TUI. OpenCode створено користувачами neovim та авторами [terminal.shop](https://terminal.shop); ми й надалі розширюватимемо межі можливого в терміналі.
- Клієнт-серверна архітектура. Наприклад, це дає змогу запускати OpenCode на вашому комп'ютері й керувати ним віддалено з мобільного застосунку, тобто TUI-фронтенд - лише один із можливих клієнтів.
---
**Приєднуйтеся до нашої спільноти** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
+12
View File
@@ -124,6 +124,18 @@ Nếu bạn muốn đóng góp cho OpenCode, vui lòng đọc [tài liệu hư
Nếu bạn đang làm việc trên một dự án liên quan đến OpenCode và sử dụng "opencode" như một phần của tên dự án, ví dụ "opencode-dashboard" hoặc "opencode-mobile", vui lòng thêm một ghi chú vào README của bạn để làm rõ rằng dự án đó không được xây dựng bởi đội ngũ OpenCode và không liên kết với chúng tôi dưới bất kỳ hình thức nào.
### Các câu hỏi thường gặp (FAQ)
#### OpenCode khác biệt thế nào so với Claude Code?
Về mặt tính năng, nó rất giống Claude Code. Dưới đây là những điểm khác biệt chính:
- 100% mã nguồn mở
- Không bị ràng buộc với bất kỳ nhà cung cấp nào. Mặc dù chúng tôi khuyên dùng các mô hình được cung cấp qua [OpenCode Zen](https://opencode.ai/zen), OpenCode có thể được sử dụng với Claude, OpenAI, Google, hoặc thậm chí các mô hình chạy cục bộ. Khi các mô hình phát triển, khoảng cách giữa chúng sẽ thu hẹp lại và giá cả sẽ giảm, vì vậy việc không phụ thuộc vào nhà cung cấp là rất quan trọng.
- Hỗ trợ LSP ngay từ đầu
- Tập trung vào TUI (Giao diện người dùng dòng lệnh). OpenCode được xây dựng bởi những người dùng neovim và đội ngũ tạo ra [terminal.shop](https://terminal.shop); chúng tôi sẽ đẩy giới hạn của những gì có thể làm được trên terminal lên mức tối đa.
- Kiến trúc client/server. Chẳng hạn, điều này cho phép OpenCode chạy trên máy tính của bạn trong khi bạn điều khiển nó từ xa qua một ứng dụng di động, nghĩa là frontend TUI chỉ là một trong những client có thể dùng.
---
**Tham gia cộng đồng của chúng tôi** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
+12
View File
@@ -123,6 +123,18 @@ OpenCode 内置两种 Agent,可用 `Tab` 键快速切换:
如果你在项目名中使用了 “opencode”(如 “opencode-dashboard” 或 “opencode-mobile”),请在 README 里注明该项目不是 OpenCode 团队官方开发,且不存在隶属关系。
### 常见问题 (FAQ)
#### 这和 Claude Code 有什么不同?
功能上很相似,关键差异:
- 100% 开源。
- 不绑定特定提供商。推荐使用 [OpenCode Zen](https://opencode.ai/zen) 的模型,但也可搭配 Claude、OpenAI、Google 甚至本地模型。模型迭代会缩小差异、降低成本,因此保持 provider-agnostic 很重要。
- 内置 LSP 支持。
- 聚焦终端界面 (TUI)。OpenCode 由 Neovim 爱好者和 [terminal.shop](https://terminal.shop) 的创建者打造,会持续探索终端的极限。
- 客户端/服务器架构。可在本机运行,同时用移动设备远程驱动。TUI 只是众多潜在客户端之一。
---
**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode)
+12
View File
@@ -123,6 +123,18 @@ OpenCode 內建了兩種 Agent,您可以使用 `Tab` 鍵快速切換。
如果您正在開發與 OpenCode 相關的專案,並在名稱中使用了 "opencode"(例如 "opencode-dashboard" 或 "opencode-mobile"),請在您的 README 中加入聲明,說明該專案並非由 OpenCode 團隊開發,且與我們沒有任何隸屬關係。
### 常見問題 (FAQ)
#### 這跟 Claude Code 有什麼不同?
在功能面上與 Claude Code 非常相似。以下是關鍵差異:
- 100% 開源。
- 不綁定特定的服務提供商。雖然我們推薦使用透過 [OpenCode Zen](https://opencode.ai/zen) 提供的模型,但 OpenCode 也可搭配 Claude, OpenAI, Google 甚至本地模型使用。隨著模型不斷演進,彼此間的差距會縮小且價格會下降,因此具備「不限廠商 (provider-agnostic)」的特性至關重要。
- 內建 LSP (語言伺服器協定) 支援。
- 專注於終端機介面 (TUI)。OpenCode 由 Neovim 愛好者與 [terminal.shop](https://terminal.shop) 的創作者打造。我們將不斷挑戰終端機介面的極限。
- 客戶端/伺服器架構 (Client/Server Architecture)。這讓 OpenCode 能夠在您的電腦上運行的同時,由行動裝置進行遠端操控。這意味著 TUI 前端只是眾多可能的客戶端之一。
---
**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode)
+17 -17
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.15.4",
"version": "1.15.3",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -84,7 +84,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.15.4",
"version": "1.15.3",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -119,7 +119,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.15.4",
"version": "1.15.3",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -146,7 +146,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.15.4",
"version": "1.15.3",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -168,7 +168,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.15.4",
"version": "1.15.3",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -192,7 +192,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.15.4",
"version": "1.15.3",
"bin": {
"opencode": "./bin/opencode",
},
@@ -253,7 +253,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.15.4",
"version": "1.15.3",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -307,7 +307,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.15.4",
"version": "1.15.3",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -337,7 +337,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.15.4",
"version": "1.15.3",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -353,7 +353,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "1.15.4",
"version": "1.15.3",
"dependencies": {
"@effect/platform-node": "catalog:",
"effect": "catalog:",
@@ -366,7 +366,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.15.4",
"version": "1.15.3",
"dependencies": {
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
@@ -384,7 +384,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.15.4",
"version": "1.15.3",
"bin": {
"opencode": "./bin/opencode",
},
@@ -520,7 +520,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.15.4",
"version": "1.15.3",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@@ -558,7 +558,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.15.4",
"version": "1.15.3",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -573,7 +573,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.15.4",
"version": "1.15.3",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -608,7 +608,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.15.4",
"version": "1.15.3",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -657,7 +657,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.15.4",
"version": "1.15.3",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
+4 -18
View File
@@ -46,24 +46,10 @@ const modelHttpErrorsQuery = (product: "go" | "zen") => {
]
const failedHttpStatus = calculatedField({
name: "is_failed_http_status",
expression: `
IF(
AND(
GTE($status, "400"),
NOT(EQUALS($status, "401")),
NOT(
AND(
EQUALS($status, "429"),
OR(
EQUALS($error.type, "GoUsageLimitError"),
EQUALS($error.type, "FreeUsageLimitError")
)
)
)
),
1,
0
)`,
expression:
product === "go"
? `IF(AND(GTE($status, "400"), NOT(EQUALS($status, "401")), NOT(EQUALS($status, "429"))), 1, 0)`
: `IF(AND(EQUALS($status, "429"), $isFreeTier), 0, AND(GTE($status, "400"), NOT(EQUALS($status, "401"))), 1, 0)`,
})
return honeycomb.getQuerySpecificationOutput({
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.15.4",
"version": "1.15.3",
"description": "",
"type": "module",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.15.4",
"version": "1.15.3",
"type": "module",
"license": "MIT",
"scripts": {
@@ -161,9 +161,7 @@ export async function POST(input: APIEvent) {
})
if (userEmail) {
if (coupon === LiteData.firstMonth50Coupon) {
await Billing.redeemCoupon(userEmail, "GO1MONTH50")
} else if (coupon === LiteData.firstMonth100Coupon) {
if (coupon === LiteData.firstMonth100Coupon) {
await Billing.redeemCoupon(userEmail, "GOFREEMONTH")
} else if (coupon === LiteData.threeMonths100Coupon) {
await Billing.redeemCoupon(userEmail, "GO3MONTHS100")
@@ -10,11 +10,8 @@ export function createRateLimiter(modelId: string, rateLimit: number | undefined
const dict = i18n(localeFromRequest(request))
const limits = Subscription.getFreeLimits()
const headersExist = Object.entries(limits.checkHeaders).every(
([name, value]) => request.headers.get(name)?.toLowerCase().includes(value) ?? false,
)
const dailyLimit = !headersExist ? limits.dailyRequestsFallback : (rateLimit ?? limits.dailyRequests)
const isDefaultModel = headersExist && !rateLimit
const dailyLimit = rateLimit ?? limits.dailyRequests
const isDefaultModel = !rateLimit
const ip = !rawIp.length ? "unknown" : rawIp
const now = Date.now()
@@ -1 +0,0 @@
ALTER TABLE `coupon` MODIFY COLUMN `type` enum('BUILDATHON','GO1MONTH50','GOFREEMONTH','GO3MONTHS100','GO6MONTHS100','GO12MONTHS100') NOT NULL;
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.15.4",
"version": "1.15.3",
"private": true,
"type": "module",
"license": "MIT",
@@ -10,7 +10,7 @@ if (!stage) throw new Error("Stage is required")
const root = path.resolve(process.cwd(), "..", "..", "..")
// read the secret
const ret = await $`bun sst secret list --fallback`.cwd(root).text()
const ret = await $`bun sst secret list --stage frank`.cwd(root).text()
const lines = ret.split("\n")
const value = lines.find((line) => line.startsWith("ZEN_LIMITS"))?.split("=")[1]
if (!value) throw new Error("ZEN_LIMITS not found")
@@ -6,7 +6,7 @@ import os from "os"
import { Subscription } from "../src/subscription"
const root = path.resolve(process.cwd(), "..", "..", "..")
const secrets = await $`bun sst secret list --fallback`.cwd(root).text()
const secrets = await $`bun sst secret list --stage frank`.cwd(root).text()
// read value
const lines = secrets.split("\n")
@@ -25,6 +25,4 @@ const newValue = JSON.stringify(JSON.parse(await tempFile.text()))
Subscription.validate(JSON.parse(newValue))
// update the secret
const envFile = Bun.file(path.join(os.tmpdir(), `limits-${Date.now()}.env`))
await envFile.write(`ZEN_LIMITS="${newValue.replace(/"/g, '\\"')}"`)
await $`bun sst secret load ${envFile.name} --fallback`.cwd(root)
await $`bun sst secret set ZEN_LIMITS ${newValue} --stage frank`.cwd(root)
+33 -41
View File
@@ -156,32 +156,33 @@ export namespace Billing {
}
export const redeemCoupon = async (email: string, type: (typeof CouponType)[number]) => {
// validate coupon type
await (async () => {
if (type === "GO1MONTH50") return
const coupon = await Database.use((tx) =>
tx
.select()
.from(CouponTable)
.where(and(eq(CouponTable.email, email), eq(CouponTable.type, type)))
.then((rows) => rows[0]),
)
if (!coupon) throw new Error("Invalid coupon code")
if (coupon.timeRedeemed) throw new Error("Coupon already redeemed")
})()
const coupon = await Database.use((tx) =>
tx
.select()
.from(CouponTable)
.where(and(eq(CouponTable.email, email), eq(CouponTable.type, type)))
.then((rows) => rows[0]),
)
if (!coupon) throw new Error("Invalid coupon code")
if (coupon.timeRedeemed) throw new Error("Coupon already redeemed")
// handle coupon type
if (type === "BUILDATHON") await grantCredit(Actor.workspace(), 500)
await Database.use((tx) =>
tx
.insert(CouponTable)
.values({ email, type, timeRedeemed: sql`now()` })
.onDuplicateKeyUpdate({
set: {
timeRedeemed: sql`now()`,
},
}),
.update(CouponTable)
.set({ timeRedeemed: sql`now()` })
.where(and(eq(CouponTable.email, email), eq(CouponTable.type, type))),
)
}
export const getCoupons = async (email: string) => {
return await Database.use((tx) =>
tx
.select({ type: CouponTable.type, timeRedeemed: CouponTable.timeRedeemed })
.from(CouponTable)
.where(and(eq(CouponTable.email, email), isNull(CouponTable.timeRedeemed)))
.then((rows) => rows.map((row) => row.type)),
)
}
@@ -289,29 +290,20 @@ export namespace Billing {
if (billing.subscriptionID) throw new Error("Already subscribed to Black")
if (billing.liteSubscriptionID) throw new Error("Already subscribed to Lite")
const coupons = await Database.use((tx) =>
tx
.select({ type: CouponTable.type, timeRedeemed: CouponTable.timeRedeemed })
.from(CouponTable)
.where(eq(CouponTable.email, email)),
)
const coupon = (() => {
if (coupons.some((coupon) => coupon.type === "GO12MONTHS100" && !coupon.timeRedeemed))
return LiteData.twelveMonths100Coupon
if (coupons.some((coupon) => coupon.type === "GO6MONTHS100" && !coupon.timeRedeemed))
return LiteData.sixMonths100Coupon
if (coupons.some((coupon) => coupon.type === "GO3MONTHS100" && !coupon.timeRedeemed))
return LiteData.threeMonths100Coupon
if (coupons.some((coupon) => coupon.type === "GOFREEMONTH" && !coupon.timeRedeemed))
return LiteData.firstMonth100Coupon
if (!coupons.some((coupon) => coupon.type === "GO1MONTH50")) return LiteData.firstMonth50Coupon
return undefined
})()
const coupons = await Billing.getCoupons(email)
const coupon = coupons.includes("GO12MONTHS100")
? LiteData.twelveMonths100Coupon
: coupons.includes("GO6MONTHS100")
? LiteData.sixMonths100Coupon
: coupons.includes("GO3MONTHS100")
? LiteData.threeMonths100Coupon
: coupons.includes("GOFREEMONTH")
? LiteData.firstMonth100Coupon
: LiteData.firstMonth50Coupon
const createSession = () =>
Billing.stripe().checkout.sessions.create({
mode: "subscription",
discounts: coupon ? [{ coupon }] : undefined,
discounts: [{ coupon }],
...(billing.customerID
? {
customer: billing.customerID,
@@ -133,14 +133,7 @@ export const UsageTable = mysqlTable(
(table) => [...workspaceIndexes(table), index("usage_time_created").on(table.workspaceID, table.timeCreated)],
)
export const CouponType = [
"BUILDATHON",
"GO1MONTH50",
"GOFREEMONTH",
"GO3MONTHS100",
"GO6MONTHS100",
"GO12MONTHS100",
] as const
export const CouponType = ["BUILDATHON", "GOFREEMONTH", "GO3MONTHS100", "GO6MONTHS100", "GO12MONTHS100"] as const
export const CouponTable = mysqlTable(
"coupon",
{
@@ -9,8 +9,6 @@ export namespace Subscription {
free: z.object({
promoTokens: z.number().int(),
dailyRequests: z.number().int(),
dailyRequestsFallback: z.number().int(),
checkHeaders: z.record(z.string(), z.string()),
}),
lite: z.object({
rollingLimit: z.number().int(),
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.15.4",
"version": "1.15.3",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.15.4",
"version": "1.15.3",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.4",
"version": "1.15.3",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
-242
View File
@@ -1,242 +0,0 @@
export * as AuthWellKnown from "./auth-well-known"
import path from "path"
import { Context, Effect, Layer, Option, Schema, SynchronizedRef } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
import { AppFileSystem } from "./filesystem"
import { Global } from "./global"
import { Substitution } from "./substitution"
export class Entry extends Schema.Class<Entry>("AuthWellKnown.Entry")({
key: Schema.String,
token: Schema.String,
}) {}
export class FileWriteError extends Schema.TaggedErrorClass<FileWriteError>()("AuthWellKnown.FileWriteError", {
operation: Schema.Union([Schema.Literal("migrate"), Schema.Literal("write")]),
cause: Schema.Defect,
}) {}
export class RemoteConfigError extends Schema.TaggedErrorClass<RemoteConfigError>()("AuthWellKnown.RemoteConfigError", {
url: Schema.String,
status: Schema.Number.pipe(Schema.optional),
cause: Schema.Defect.pipe(Schema.optional),
}) {}
export type Error = FileWriteError | RemoteConfigError
const RemoteConfig = Schema.Struct({
url: Schema.String,
headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
})
export class Metadata extends Schema.Class<Metadata>("AuthWellKnown.Metadata")({
auth: Schema.Struct({
command: Schema.Array(Schema.String),
env: Schema.String,
}).pipe(Schema.optional),
config: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
remote_config: RemoteConfig.pipe(Schema.optional),
}) {}
export type ConfigDocument = {
url: string
source: string
dir: string
content: unknown
}
export interface Interface {
readonly all: () => Effect.Effect<Record<string, Entry>, Error>
readonly get: (url: string) => Effect.Effect<Entry | undefined, Error>
readonly set: (url: string, entry: Entry) => Effect.Effect<void, Error>
readonly remove: (url: string) => Effect.Effect<void, Error>
readonly metadata: (url: string) => Effect.Effect<Metadata, Error>
readonly configs: () => Effect.Effect<ConfigDocument[], Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/AuthWellKnown") {}
const decodeMetadata = Schema.decodeUnknownEffect(Metadata)
const decodeRemoteConfig = Schema.decodeUnknownEffect(RemoteConfig)
function loadLegacyAuth(input: {
fsys: AppFileSystem.Interface
dataDir: string
write: (data: Record<string, Entry>) => Effect.Effect<void, Error>
}) {
return Effect.gen(function* () {
const decodeLegacy = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Unknown))
const decodeLegacyCredential = Schema.decodeUnknownOption(
Schema.Struct({
type: Schema.Literal("wellknown"),
key: Schema.String,
token: Schema.String,
}),
)
const legacy = Object.fromEntries(
Object.entries(
Option.getOrElse(
decodeLegacy(
yield* input.fsys.readJson(path.join(input.dataDir, "auth.json")).pipe(Effect.orElseSucceed(() => null)),
),
() => ({}),
),
).flatMap(([url, value]) => {
const decoded = Option.getOrUndefined(decodeLegacyCredential(value))
return decoded ? [[url.replace(/\/+$/, ""), new Entry({ key: decoded.key, token: decoded.token })]] : []
}),
)
if (Object.keys(legacy).length > 0) yield* input.write(legacy).pipe(Effect.ignore)
return legacy
})
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const global = yield* Global.Service
const http = yield* HttpClient.HttpClient
const substitution = yield* Substitution.Service
const file = path.join(global.data, "well-known.json")
const decodeEntries = Schema.decodeUnknownOption(Schema.Record(Schema.String, Entry))
const normalizeUrl = (url: string) => url.replace(/\/+$/, "")
const write = (operation: "migrate" | "write", data: Record<string, Entry>) =>
fsys.writeJson(file, data, 0o600).pipe(Effect.mapError((cause) => new FileWriteError({ operation, cause })))
const load: () => Effect.Effect<Record<string, Entry>> = Effect.fnUntraced(function* () {
const current = yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => null))
if (current && typeof current === "object")
return Option.getOrElse(decodeEntries(current), () => ({}) as Record<string, Entry>)
return yield* loadLegacyAuth({ fsys, dataDir: global.data, write: (data) => write("migrate", data) })
})
const state = SynchronizedRef.makeUnsafe<Record<string, Entry>>(yield* load())
const metadata = Effect.fn("AuthWellKnown.metadata")(function* (url: string) {
const normalized = normalizeUrl(url)
const source = `${normalized}/.well-known/opencode`
const response = yield* HttpClientRequest.get(source).pipe(
HttpClientRequest.acceptJson,
http.execute,
Effect.mapError((cause) => new RemoteConfigError({ url: source, cause })),
)
if (response.status < 200 || response.status >= 300) {
return yield* new RemoteConfigError({ url: source, status: response.status })
}
const metadata = yield* response.json.pipe(
Effect.flatMap(decodeMetadata),
Effect.mapError((cause) => new RemoteConfigError({ url: source, cause })),
)
return { url: normalized, source, dir: path.dirname(source), metadata }
})
const remote = Effect.fn("AuthWellKnown.remote")(function* (input: { url: string; headers?: Record<string, string> }) {
const response = yield* HttpClientRequest.get(input.url).pipe(
HttpClientRequest.acceptJson,
input.headers ? HttpClientRequest.setHeaders(input.headers) : (request) => request,
http.execute,
Effect.mapError((cause) => new RemoteConfigError({ url: input.url, cause })),
)
if (response.status < 200 || response.status >= 300) {
return yield* new RemoteConfigError({ url: input.url, status: response.status })
}
return yield* response.json.pipe(Effect.mapError((cause) => new RemoteConfigError({ url: input.url, cause })))
})
return Service.of({
all: Effect.fn("AuthWellKnown.all")(function* () {
return yield* SynchronizedRef.get(state)
}),
get: Effect.fn("AuthWellKnown.get")(function* (url) {
return (yield* SynchronizedRef.get(state))[normalizeUrl(url)]
}),
set: Effect.fn("AuthWellKnown.set")(function* (url, entry) {
yield* SynchronizedRef.updateEffect(
state,
Effect.fnUntraced(function* (data) {
const next = { ...data, [normalizeUrl(url)]: entry }
yield* write("write", next)
return next
}),
)
}),
remove: Effect.fn("AuthWellKnown.remove")(function* (url) {
yield* SynchronizedRef.updateEffect(
state,
Effect.fnUntraced(function* (data) {
const next = { ...data }
delete next[url]
delete next[normalizeUrl(url)]
yield* write("write", next)
return next
}),
)
}),
metadata: Effect.fn("AuthWellKnown.metadata.public")(function* (url) {
return (yield* metadata(url)).metadata
}),
configs: Effect.fn("AuthWellKnown.configs")(function* () {
const documents = yield* Effect.all(
Object.entries(yield* SynchronizedRef.get(state)).map(([url, entry]) =>
Effect.gen(function* () {
const configs: ConfigDocument[] = []
const response = yield* metadata(url)
const env = { [entry.key]: entry.token }
if (response.metadata.config) {
configs.push({
url: response.url,
source: response.source,
dir: response.dir,
content: response.metadata.config,
})
}
if (response.metadata.remote_config) {
const remoteConfig = yield* substitution
.substitute({
text: JSON.stringify(response.metadata.remote_config),
type: "virtual",
dir: response.url,
source: response.source,
env,
})
.pipe(
Effect.flatMap((text) =>
Effect.try({
try: () => JSON.parse(text) as unknown,
catch: (cause) => new RemoteConfigError({ url: response.source, cause }),
}),
),
Effect.flatMap(decodeRemoteConfig),
Effect.mapError((cause) => new RemoteConfigError({ url: response.source, cause })),
)
configs.push({
url: remoteConfig.url,
source: remoteConfig.url,
dir: path.dirname(remoteConfig.url),
content: yield* remote({ url: remoteConfig.url, headers: remoteConfig.headers }),
})
}
return configs
}),
),
{ concurrency: "unbounded" },
)
return documents.flat()
}),
})
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Global.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(Substitution.defaultLayer),
)
-94
View File
@@ -1,94 +0,0 @@
export * as Substitution from "./substitution"
import os from "os"
import path from "path"
import { Context, Effect, Layer, Schema } from "effect"
import { AppFileSystem } from "./filesystem"
type Source =
| {
type: "path"
path: string
}
| {
type: "virtual"
source: string
dir: string
}
export type Input = Source & {
text: string
missing?: "error" | "empty"
env?: Record<string, string | undefined>
}
export class FileReferenceError extends Schema.TaggedErrorClass<FileReferenceError>()("Substitution.FileReferenceError", {
source: Schema.String,
token: Schema.String,
resolved: Schema.String,
cause: Schema.Defect,
}) {}
export type Error = FileReferenceError
export interface Interface {
readonly substitute: (input: Input) => Effect.Effect<string, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Substitution") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
return Service.of({
substitute: Effect.fn("Substitution.substitute")(function* (input) {
const missing = input.missing ?? "error"
const text = input.text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
return input.env?.[varName] ?? process.env[varName] ?? ""
})
const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g))
if (!fileMatches.length) return text
const configDir = input.type === "path" ? path.dirname(input.path) : input.dir
const configSource = input.type === "path" ? input.path : input.source
let out = ""
let cursor = 0
for (const match of fileMatches) {
const token = match[0]
const index = match.index!
out += text.slice(cursor, index)
const lineStart = text.lastIndexOf("\n", index - 1) + 1
const prefix = text.slice(lineStart, index).trimStart()
if (prefix.startsWith("//")) {
out += token
cursor = index + token.length
continue
}
const reference = token.replace(/^\{file:/, "").replace(/\}$/, "")
const filepath = reference.startsWith("~/") ? path.join(os.homedir(), reference.slice(2)) : reference
const resolved = path.isAbsolute(filepath) ? filepath : path.resolve(configDir, filepath)
const content = yield* fs.readFileString(resolved).pipe(
Effect.catch((cause) => {
if (missing === "empty") return Effect.succeed("")
return Effect.fail(new FileReferenceError({ source: configSource, token, resolved, cause }))
}),
)
out += JSON.stringify(content.trim()).slice(1, -1)
cursor = index + token.length
}
out += text.slice(cursor)
return out
}),
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
-161
View File
@@ -1,161 +0,0 @@
import { describe, expect } from "bun:test"
import path from "path"
import { Effect, Layer } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Global } from "@opencode-ai/core/global"
import { Substitution } from "@opencode-ai/core/substitution"
import { AuthWellKnown } from "@opencode-ai/core/auth-well-known"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(Layer.empty)
const unexpectedHttpClient = HttpClient.make((request) => Effect.die(`unexpected http request: ${request.url}`))
const withAuthWellKnown = <A, E, R>(
dir: string,
effect: Effect.Effect<A, E, R | AuthWellKnown.Service>,
client = unexpectedHttpClient,
) =>
effect.pipe(
Effect.provide(AuthWellKnown.layer),
Effect.provide(AppFileSystem.defaultLayer),
Effect.provide(Global.layerWith({ data: dir })),
Effect.provide(Layer.succeed(HttpClient.HttpClient, client)),
Effect.provide(Substitution.defaultLayer),
)
const wellKnownConfigClient = HttpClient.make((request) => {
if (request.url === "https://example.com/.well-known/opencode") {
return Effect.succeed(
HttpClientResponse.fromWeb(
request,
Response.json({
config: { instructions: ["local"] },
remote_config: {
url: "https://remote.example.com/config",
headers: {
authorization: "Bearer {env:TEST_TOKEN}",
},
},
}),
),
)
}
if (request.url === "https://remote.example.com/config") {
expect(request.headers.authorization).toBe("Bearer secret")
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ model: "remote/model" })))
}
return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 404 })))
})
describe("AuthWellKnown", () => {
it.live("stores well-known credentials", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* withAuthWellKnown(
tmp.path,
Effect.gen(function* () {
const auth = yield* AuthWellKnown.Service
yield* auth.set("https://example.com/", new AuthWellKnown.Entry({ key: "TEST_TOKEN", token: "secret" }))
}),
)
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, "well-known.json")).json())).toEqual({
"https://example.com": {
key: "TEST_TOKEN",
token: "secret",
},
})
}),
)
it.live("migrates legacy well-known auth records", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(() =>
Bun.write(
path.join(tmp.path, "auth.json"),
JSON.stringify({
"https://example.com": {
type: "wellknown",
key: "TEST_TOKEN",
token: "secret",
},
}),
),
)
const entry = yield* withAuthWellKnown(
tmp.path,
Effect.gen(function* () {
const auth = yield* AuthWellKnown.Service
return yield* auth.get("https://example.com/")
}),
)
expect(entry).toEqual({
key: "TEST_TOKEN",
token: "secret",
})
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, "well-known.json")).json())).toEqual({
"https://example.com": {
key: "TEST_TOKEN",
token: "secret",
},
})
}),
)
it.live("loads config documents", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(() =>
Bun.write(
path.join(tmp.path, "well-known.json"),
JSON.stringify({
"https://example.com": {
key: "TEST_TOKEN",
token: "secret",
},
}),
),
)
const result = yield* withAuthWellKnown(
tmp.path,
Effect.gen(function* () {
const auth = yield* AuthWellKnown.Service
return yield* auth.configs()
}),
wellKnownConfigClient,
)
expect(result).toEqual([
{
url: "https://example.com",
source: "https://example.com/.well-known/opencode",
dir: "https://example.com/.well-known",
content: { instructions: ["local"] },
},
{
url: "https://remote.example.com/config",
source: "https://remote.example.com/config",
dir: "https://remote.example.com",
content: { model: "remote/model" },
},
])
}),
)
})
-49
View File
@@ -1,49 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Global } from "@opencode-ai/core/global"
import { AuthV2 } from "@opencode-ai/core/auth"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(Layer.empty)
const withAuth = <A, E, R>(dir: string, effect: Effect.Effect<A, E, R | AuthV2.Service>) =>
effect.pipe(
Effect.provide(AuthV2.layer),
Effect.provide(AppFileSystem.defaultLayer),
Effect.provide(Global.layerWith({ data: dir })),
)
describe("AuthV2", () => {
it.live("stores api credentials", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const account = yield* withAuth(
tmp.path,
Effect.gen(function* () {
const auth = yield* AuthV2.Service
return yield* auth.create({
serviceID: AuthV2.ServiceID.make("anthropic"),
credential: new AuthV2.ApiKeyCredential({ type: "api", key: "sk-test" }),
})
}),
)
const active = yield* withAuth(
tmp.path,
Effect.gen(function* () {
const auth = yield* AuthV2.Service
return yield* auth.active(AuthV2.ServiceID.make("anthropic"))
}),
)
expect(active?.id).toBe(account.id)
expect(active?.credential).toEqual({ type: "api", key: "sk-test" })
}),
)
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.15.4",
"version": "1.15.3",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.15.4",
"version": "1.15.3",
"private": true,
"type": "module",
"license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.15.4"
version = "1.15.3"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.4/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.3/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.4/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.3/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.4/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.3/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.4/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.3/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.4/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.3/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.15.4",
"version": "1.15.3",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.4",
"version": "1.15.3",
"name": "@opencode-ai/http-recorder",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.4",
"version": "1.15.3",
"name": "@opencode-ai/llm",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.4",
"version": "1.15.3",
"name": "opencode",
"type": "module",
"license": "MIT",
+1 -4
View File
@@ -6,8 +6,6 @@ import { GlobalBus } from "./global"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Identifier } from "@/id/id"
import type { InstanceContext } from "@/project/instance-context"
import { InstanceRef } from "@/effect/instance-ref"
const log = Log.create({ service: "bus" })
@@ -187,12 +185,11 @@ export function createID() {
}
export async function publish<D extends BusEvent.Definition>(
ctx: InstanceContext,
def: D,
properties: BusProperties<D>,
options?: { id?: string },
) {
return runPromise((svc) => svc.publish(def, properties, options).pipe(Effect.provideService(InstanceRef, ctx)))
return runPromise((svc) => svc.publish(def, properties, options))
}
export function subscribe<D extends BusEvent.Definition>(def: D, callback: (event: Payload<D>) => unknown) {
+14 -34
View File
@@ -1,5 +1,4 @@
import { Auth } from "../../auth"
import { AuthWellKnown } from "@opencode-ai/core/auth-well-known"
import { cmd } from "./cmd"
import { CliError, effectCmd, fail } from "../effect-cmd"
import { UI } from "../ui"
@@ -253,7 +252,6 @@ export const ProvidersListCommand = effectCmd({
instance: false,
handler: Effect.fn("Cli.providers.list")(function* (_args) {
const authSvc = yield* Auth.Service
const authWellKnown = yield* AuthWellKnown.Service
const modelsDev = yield* ModelsDev.Service
UI.empty()
@@ -261,8 +259,7 @@ export const ProvidersListCommand = effectCmd({
const homedir = os.homedir()
const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath
yield* Prompt.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
const results = Object.entries(yield* Effect.orDie(authSvc.all())).filter(([, result]) => result.type !== "wellknown")
const wellKnownResults = Object.entries(yield* Effect.orDie(authWellKnown.all()))
const results = Object.entries(yield* Effect.orDie(authSvc.all()))
const database = yield* modelsDev.get()
for (const [providerID, result] of results) {
@@ -270,11 +267,7 @@ export const ProvidersListCommand = effectCmd({
yield* Prompt.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`)
}
for (const [url] of wellKnownResults) {
yield* Prompt.log.info(`${url} ${UI.Style.TEXT_DIM}wellknown`)
}
yield* Prompt.outro(`${results.length + wellKnownResults.length} credentials`)
yield* Prompt.outro(`${results.length} credentials`)
const activeEnvVars: Array<{ provider: string; envVar: string }> = []
@@ -323,19 +316,19 @@ export const ProvidersLoginCommand = effectCmd({
}),
handler: Effect.fn("Cli.providers.login")(function* (args) {
const authSvc = yield* Auth.Service
const authWellKnown = yield* AuthWellKnown.Service
UI.empty()
yield* Prompt.intro("Add credential")
if (args.url) {
const url = args.url.replace(/\/+$/, "")
const wellknown = yield* authWellKnown.metadata(url).pipe(
Effect.mapError((error) => new CliError({ message: `Failed to load auth provider metadata from ${url}: ${errorMessage(error)}` })),
)
if (!wellknown.auth) return yield* fail(`Auth provider metadata from ${url} is missing auth configuration`)
const wellknown = (yield* cliTry(`Failed to load auth provider metadata from ${url}: `, () =>
fetch(`${url}/.well-known/opencode`).then((x) => x.json()),
)) as {
auth: { command: string[]; env: string }
}
yield* Prompt.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
const abort = new AbortController()
const proc = Process.spawn([...wellknown.auth.command], { stdout: "pipe", stderr: "inherit", abort: abort.signal })
const proc = Process.spawn(wellknown.auth.command, { stdout: "pipe", stderr: "inherit", abort: abort.signal })
if (!proc.stdout) {
yield* Prompt.log.error("Failed")
yield* Prompt.outro("Done")
@@ -349,7 +342,7 @@ export const ProvidersLoginCommand = effectCmd({
yield* Prompt.outro("Done")
return
}
yield* Effect.orDie(authWellKnown.set(url, new AuthWellKnown.Entry({ key: wellknown.auth.env, token: token.trim() })))
yield* Effect.orDie(authSvc.set(url, { type: "wellknown", key: wellknown.auth.env, token: token.trim() }))
yield* Prompt.log.success("Logged into " + url)
yield* Prompt.outro("Done")
return
@@ -499,20 +492,10 @@ export const ProvidersLogoutCommand = effectCmd({
instance: false,
handler: Effect.fn("Cli.providers.logout")(function* (_args) {
const authSvc = yield* Auth.Service
const authWellKnown = yield* AuthWellKnown.Service
const modelsDev = yield* ModelsDev.Service
UI.empty()
const credentials = [
...Object.entries(yield* Effect.orDie(authSvc.all()))
.filter(([, value]) => value.type !== "wellknown")
.map(([key, value]) => ({ key, type: value.type, auth: "provider" as const })),
...Object.keys(yield* Effect.orDie(authWellKnown.all())).map((key) => ({
key,
type: "wellknown" as const,
auth: "wellknown" as const,
})),
]
const credentials: Array<[string, Auth.Info]> = Object.entries(yield* Effect.orDie(authSvc.all()))
yield* Prompt.intro("Remove credential")
if (credentials.length === 0) {
yield* Prompt.log.error("No credentials found")
@@ -521,15 +504,12 @@ export const ProvidersLogoutCommand = effectCmd({
const database = yield* modelsDev.get()
const selected = yield* Prompt.select({
message: "Select provider",
options: credentials.map((item, index) => ({
label: (database[item.key]?.name || item.key) + UI.Style.TEXT_DIM + " (" + item.type + ")",
value: index,
options: credentials.map(([key, value]) => ({
label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
value: key,
})),
})
const credential = credentials[yield* promptValue(selected)]
if (!credential) return
if (credential.auth === "wellknown") yield* Effect.orDie(authWellKnown.remove(credential.key))
else yield* Effect.orDie(authSvc.remove(credential.key))
yield* Effect.orDie(authSvc.remove(yield* promptValue(selected)))
yield* Prompt.outro("Logout successful")
}),
})
@@ -859,7 +859,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
})
event.on("installation.update-available", async (evt) => {
console.log("installation.update-available", evt)
const version = evt.properties.version
const skipped = kv.get("skipped_version")
@@ -12,7 +12,6 @@ import { Flag } from "@opencode-ai/core/flag/flag"
import { isRecord } from "@/util/record"
import { Global } from "@opencode-ai/core/global"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Substitution } from "@opencode-ai/core/substitution"
import { CurrentWorkingDirectory } from "./cwd"
import { ConfigPlugin } from "@/config/plugin"
import { TuiKeybind } from "./keybind"
@@ -20,6 +19,7 @@ import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/instal
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
import { Filesystem } from "@/util/filesystem"
import * as Log from "@opencode-ai/core/util/log"
import { ConfigVariable } from "@/config/variable"
import { Npm } from "@opencode-ai/core/npm"
import type { DeepMutable } from "@opencode-ai/core/schema"
import type { TuiAttentionSoundName } from "@opencode-ai/plugin/tui"
@@ -98,7 +98,6 @@ function dropUnknownKeybinds(input: Record<string, unknown>, configFilepath: str
const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: string }) {
const afs = yield* AppFileSystem.Service
const substitution = yield* Substitution.Service
let appliedOrder = 0
const resolvePlugins = (config: Info, configFilepath: string): Effect.Effect<Info> =>
@@ -113,7 +112,9 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
const load = (text: string, configFilepath: string): Effect.Effect<Info> =>
Effect.gen(function* () {
const expanded = yield* substitution.substitute({ text, type: "path", path: configFilepath, missing: "empty" }).pipe(Effect.orDie)
const expanded = yield* Effect.promise(() =>
ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty" }),
)
const data = ConfigParse.jsonc(expanded, configFilepath)
if (!isRecord(data)) return {} as Info
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
@@ -294,11 +295,7 @@ export const layer = Layer.effect(
}).pipe(Effect.withSpan("TuiConfig.layer")),
)
export const defaultLayer = layer.pipe(
Layer.provide(Npm.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Substitution.defaultLayer),
)
export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer), Layer.provide(AppFileSystem.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
@@ -962,7 +962,6 @@ function getSyntaxRules(theme: Theme) {
style: {
foreground: theme.markdownHeading,
bold: true,
underline: true,
},
},
{
+4 -24
View File
@@ -1,9 +1,9 @@
import { Bus } from "@/bus"
import { Config } from "@/config/config"
import { AppRuntime } from "@/effect/app-runtime"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Installation } from "@/installation"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { GlobalBus } from "@/bus/global"
export async function upgrade() {
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal()))
@@ -13,13 +13,7 @@ export async function upgrade() {
if (!latest) return
if (Flag.OPENCODE_ALWAYS_NOTIFY_UPDATE) {
GlobalBus.emit("event", {
directory: "global",
payload: {
type: Installation.Event.UpdateAvailable.type,
properties: { version: latest },
},
})
await Bus.publish(Installation.Event.UpdateAvailable, { version: latest })
return
}
@@ -28,26 +22,12 @@ export async function upgrade() {
const kind = Installation.getReleaseType(InstallationVersion, latest)
if (config.autoupdate === "notify" || kind !== "patch") {
GlobalBus.emit("event", {
directory: "global",
payload: {
type: Installation.Event.UpdateAvailable.type,
properties: { version: latest },
},
})
await Bus.publish(Installation.Event.UpdateAvailable, { version: latest })
return
}
if (method === "unknown") return
await Installation.upgrade(method, latest)
.then(() =>
GlobalBus.emit("event", {
directory: "global",
payload: {
type: Installation.Event.Updated.type,
properties: { version: latest },
},
}),
)
.then(() => Bus.publish(Installation.Event.Updated, { version: latest }))
.catch(() => {})
}
+14 -2
View File
@@ -1,8 +1,10 @@
export * as ConfigAgent from "./agent"
import { Exit, Schema, SchemaGetter } from "effect"
import { Bus } from "@/bus"
import { PositiveInt } from "@opencode-ai/core/schema"
import * as Log from "@opencode-ai/core/util/log"
import { NamedError } from "@opencode-ai/core/util/error"
import { Glob } from "@opencode-ai/core/util/glob"
import { configEntryNameFromPath } from "./entry-name"
import * as ConfigMarkdown from "./markdown"
@@ -110,7 +112,12 @@ export async function load(dir: string) {
dot: true,
symlink: true,
})) {
const md = await ConfigMarkdown.parse(item).catch((err) => {
const md = await ConfigMarkdown.parse(item).catch(async (err) => {
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
? err.data.message
: `Failed to parse agent ${item}`
const { Session } = await import("@/session/session")
void Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
log.error("failed to load agent", { agent: item, err })
return undefined
})
@@ -137,7 +144,12 @@ export async function loadMode(dir: string) {
dot: true,
symlink: true,
})) {
const md = await ConfigMarkdown.parse(item).catch((err) => {
const md = await ConfigMarkdown.parse(item).catch(async (err) => {
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
? err.data.message
: `Failed to parse mode ${item}`
const { Session } = await import("@/session/session")
void Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
log.error("failed to load mode", { mode: item, err })
return undefined
})
+8 -1
View File
@@ -2,7 +2,9 @@ export * as ConfigCommand from "./command"
import * as Log from "@opencode-ai/core/util/log"
import { Cause, Exit, Schema } from "effect"
import { NamedError } from "@opencode-ai/core/util/error"
import { Glob } from "@opencode-ai/core/util/glob"
import { Bus } from "@/bus"
import { configEntryNameFromPath } from "./entry-name"
import { InvalidError } from "./error"
import * as ConfigMarkdown from "./markdown"
@@ -30,7 +32,12 @@ export async function load(dir: string) {
dot: true,
symlink: true,
})) {
const md = await ConfigMarkdown.parse(item).catch((err) => {
const md = await ConfigMarkdown.parse(item).catch(async (err) => {
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
? err.data.message
: `Failed to parse command ${item}`
const { Session } = await import("@/session/session")
void Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
log.error("failed to load command", { command: item, err })
return undefined
})
+81 -16
View File
@@ -7,8 +7,7 @@ import { Global } from "@opencode-ai/core/global"
import fsNode from "fs/promises"
import { NamedError } from "@opencode-ai/core/util/error"
import { Flag } from "@opencode-ai/core/flag/flag"
import { AuthWellKnown } from "@opencode-ai/core/auth-well-known"
import { Substitution } from "@opencode-ai/core/substitution"
import { Auth } from "../auth"
import { Env } from "../env"
import { applyEdits, modify } from "jsonc-parser"
import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version"
@@ -39,6 +38,7 @@ import { ConfigProvider } from "./provider"
import { ConfigReference } from "./reference"
import { ConfigServer } from "./server"
import { ConfigSkills } from "./skills"
import { ConfigVariable } from "./variable"
import { Npm } from "@opencode-ai/core/npm"
const log = Log.create({ service: "config" })
@@ -69,6 +69,36 @@ function normalizeLoadedConfig(data: unknown, source: string) {
return copy
}
async function substituteWellKnownRemoteConfig(input: { value: unknown; dir: string; source: string }) {
if (!isRecord(input.value) || typeof input.value.url !== "string") return
const url = await ConfigVariable.substitute({
text: input.value.url,
type: "virtual",
dir: input.dir,
source: input.source,
})
const headers = isRecord(input.value.headers)
? Object.fromEntries(
await Promise.all(
Object.entries(input.value.headers)
.filter((entry): entry is [string, string] => typeof entry[1] === "string")
.map(async ([key, value]) => [
key,
await ConfigVariable.substitute({
text: value,
type: "virtual",
dir: input.dir,
source: input.source,
}),
]),
),
)
: undefined
return { url, headers }
}
async function resolveLoadedPlugins<T extends { plugin?: ConfigPlugin.Spec[] }>(config: T, filepath: string) {
if (!config.plugin) return config
for (let i = 0; i < config.plugin.length; i++) {
@@ -335,8 +365,7 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const authWellKnown = yield* AuthWellKnown.Service
const substitution = yield* Substitution.Service
const authSvc = yield* Auth.Service
const accountSvc = yield* Account.Service
const env = yield* Env.Service
const npmSvc = yield* Npm.Service
@@ -348,9 +377,11 @@ export const layer = Layer.effect(
options: { path: string } | { dir: string; source: string },
) {
const source = "path" in options ? options.path : options.source
const expanded = yield* substitution.substitute(
"path" in options ? { text, type: "path", path: options.path } : { text, type: "virtual", ...options },
).pipe(Effect.orDie)
const expanded = yield* Effect.promise(() =>
ConfigVariable.substitute(
"path" in options ? { text, type: "path", path: options.path } : { text, type: "virtual", ...options },
),
)
const parsed = ConfigParse.jsonc(expanded, source)
const data = ConfigParse.schema(Info, normalizeLoadedConfig(parsed, source), source)
if (!("path" in options)) return data
@@ -440,6 +471,8 @@ export const layer = Layer.effect(
const loadInstanceState = Effect.fn("Config.loadInstanceState")(
function* (ctx: InstanceContext) {
const auth = yield* authSvc.all().pipe(Effect.orDie)
let result: Info = {}
const consoleManagedProviders = new Set<string>()
let activeOrgName: string | undefined
@@ -477,13 +510,46 @@ export const layer = Layer.effect(
return mergePluginOrigins(source, next.plugin, kind)
}
for (const item of yield* authWellKnown.configs().pipe(Effect.orDie)) {
yield* merge(
item.source,
yield* loadConfig(JSON.stringify(item.content), { dir: item.dir, source: item.source }),
"global",
)
log.debug("loaded well-known config", { url: item.url })
for (const [key, value] of Object.entries(auth)) {
if (value.type === "wellknown") {
const url = key.replace(/\/+$/, "")
process.env[value.key] = value.token
log.debug("fetching remote config", { url: `${url}/.well-known/opencode` })
const response = yield* Effect.promise(() => fetch(`${url}/.well-known/opencode`))
if (!response.ok) {
throw new Error(`failed to fetch remote config from ${url}: ${response.status}`)
}
const wellknown = (yield* Effect.promise(() => response.json())) as {
config?: Record<string, unknown>
remote_config?: unknown
}
const remote = yield* Effect.promise(() =>
substituteWellKnownRemoteConfig({
value: wellknown.remote_config,
dir: url,
source: `${url}/.well-known/opencode`,
}),
)
const fetchedConfig = remote
? ((yield* Effect.promise(async () => {
log.debug("fetching remote config", { url: remote.url })
const response = await fetch(remote.url, { headers: remote.headers })
if (!response.ok)
throw new Error(`failed to fetch remote config from ${remote.url}: ${response.status}`)
const data = await response.json()
return isRecord(data) && isRecord(data.config) ? data.config : data
})) as Record<string, unknown>)
: {}
const remoteConfig = mergeConfig(wellknown.config ?? {}, fetchedConfig as Info)
if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
const source = `${url}/.well-known/opencode`
const next = yield* loadConfig(JSON.stringify(remoteConfig), {
dir: path.dirname(source),
source,
})
yield* merge(source, next, "global")
log.debug("loaded remote config from well-known", { url })
}
}
const global = yield* getGlobal()
@@ -759,8 +825,7 @@ export const defaultLayer = layer.pipe(
Layer.provide(EffectFlock.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(AuthWellKnown.defaultLayer),
Layer.provide(Substitution.defaultLayer),
Layer.provide(Auth.defaultLayer),
Layer.provide(Account.defaultLayer),
Layer.provide(Npm.defaultLayer),
)
+90
View File
@@ -0,0 +1,90 @@
export * as ConfigVariable from "./variable"
import path from "path"
import os from "os"
import { Filesystem } from "@/util/filesystem"
import { InvalidError } from "./error"
type ParseSource =
| {
type: "path"
path: string
}
| {
type: "virtual"
source: string
dir: string
}
type SubstituteInput = ParseSource & {
text: string
missing?: "error" | "empty"
}
function source(input: ParseSource) {
return input.type === "path" ? input.path : input.source
}
function dir(input: ParseSource) {
return input.type === "path" ? path.dirname(input.path) : input.dir
}
/** Apply {env:VAR} and {file:path} substitutions to config text. */
export async function substitute(input: SubstituteInput) {
const missing = input.missing ?? "error"
let text = input.text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
return process.env[varName] || ""
})
const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g))
if (!fileMatches.length) return text
const configDir = dir(input)
const configSource = source(input)
let out = ""
let cursor = 0
for (const match of fileMatches) {
const token = match[0]
const index = match.index!
out += text.slice(cursor, index)
const lineStart = text.lastIndexOf("\n", index - 1) + 1
const prefix = text.slice(lineStart, index).trimStart()
if (prefix.startsWith("//")) {
out += token
cursor = index + token.length
continue
}
let filePath = token.replace(/^\{file:/, "").replace(/\}$/, "")
if (filePath.startsWith("~/")) {
filePath = path.join(os.homedir(), filePath.slice(2))
}
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
const fileContent = (
await Filesystem.readText(resolvedPath).catch((error: NodeJS.ErrnoException) => {
if (missing === "empty") return ""
const errMsg = `bad file reference: "${token}"`
if (error.code === "ENOENT") {
throw new InvalidError(
{
path: configSource,
message: errMsg + ` ${resolvedPath} does not exist`,
},
{ cause: error },
)
}
throw new InvalidError({ path: configSource, message: errMsg }, { cause: error })
})
).trim()
out += JSON.stringify(fileContent).slice(1, -1)
cursor = index + token.length
}
out += text.slice(cursor)
return out
}
@@ -3,7 +3,6 @@ import { attach } from "./run-service"
import * as Observability from "@opencode-ai/core/effect/observability"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { AuthWellKnown } from "@opencode-ai/core/auth-well-known"
import { Bus } from "@/bus"
import { Auth } from "@/auth"
import { Account } from "@/account/account"
@@ -63,7 +62,6 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
export const AppLayer = Layer.mergeAll(
Npm.defaultLayer,
AppFileSystem.defaultLayer,
AuthWellKnown.defaultLayer,
Bus.defaultLayer,
Auth.defaultLayer,
Account.defaultLayer,
+4 -4
View File
@@ -96,11 +96,11 @@ export const layer = Layer.effect(
)
const cb: ParcelWatcher.SubscribeCallback = bridge.bind((err, evts) => {
// if (err) return
if (err) return
for (const evt of evts) {
if (evt.type === "create") void Bus.publish(ctx, Event.Updated, { file: evt.path, event: "add" })
if (evt.type === "update") void Bus.publish(ctx, Event.Updated, { file: evt.path, event: "change" })
if (evt.type === "delete") void Bus.publish(ctx, Event.Updated, { file: evt.path, event: "unlink" })
if (evt.type === "create") void Bus.publish(Event.Updated, { file: evt.path, event: "add" })
if (evt.type === "update") void Bus.publish(Event.Updated, { file: evt.path, event: "change" })
if (evt.type === "delete") void Bus.publish(Event.Updated, { file: evt.path, event: "unlink" })
}
})
+1 -1
View File
@@ -291,7 +291,7 @@ export const layer = Layer.effect(
if (!client) continue
result.push(client)
await Bus.publish(ctx, Event.Updated, {})
Bus.publish(Event.Updated, {})
}
return result
@@ -16,8 +16,10 @@ import { Effect, Layer, Context, Schema } from "effect"
import * as DateTime from "effect/DateTime"
import { InstanceState } from "@/effect/instance-state"
import { isOverflow as overflow, usable } from "./overflow"
import { makeRuntime } from "@/effect/run-service"
import { serviceUse } from "@/effect/service-use"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { EventV2 } from "@opencode-ai/core/event"
import { EventV2Bridge } from "@/event-v2-bridge"
import { SessionEvent } from "@opencode-ai/core/session-event"
@@ -636,4 +638,14 @@ export const defaultLayer = Layer.suspend(() =>
),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function isOverflow(input: { tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) {
return runPromise((svc) => svc.isOverflow(input))
}
export async function prune(input: { sessionID: SessionID }) {
return runPromise((svc) => svc.prune(input))
}
export * as SessionCompaction from "./compaction"
+1 -9
View File
@@ -24,14 +24,6 @@ export interface TaskPromptOps {
}
const id = "task"
const BACKGROUND_DESCRIPTION = [
"",
"",
[
"Background mode: background=true launches the subagent asynchronously.",
"Use task_status(task_id=..., wait=false) to poll, or wait=true to block until done.",
].join(" "),
].join("\n")
const BaseParameters = Schema.Struct({
description: Schema.String.annotate({ description: "A short (3-5 words) description of the task" }),
@@ -335,7 +327,7 @@ export const TaskTool = Tool.define(
})
return {
description: flags.experimentalBackgroundSubagents ? DESCRIPTION + BACKGROUND_DESCRIPTION : DESCRIPTION,
description: DESCRIPTION,
parameters: Parameters,
jsonSchema: flags.experimentalBackgroundSubagents ? undefined : ToolJsonSchema.fromSchema(BaseParameters),
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
+5 -4
View File
@@ -12,7 +12,8 @@ When NOT to use the Task tool:
Usage notes:
1. Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses
2. When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result. The output includes a task_id you can reuse later to continue the same subagent session.
3. Each agent invocation starts with a fresh context unless you provide task_id to resume the same subagent session (which continues with its previous messages and tool outputs). When starting fresh, your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you.
4. The agent's outputs should generally be trusted
5. Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent. Tell it how to verify its work if possible (e.g., relevant test commands).
6. If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement.
3. If OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS is enabled, background=true launches the subagent asynchronously. Use task_status(task_id=..., wait=false) to poll, or wait=true to block until done.
4. Each agent invocation starts with a fresh context unless you provide task_id to resume the same subagent session (which continues with its previous messages and tool outputs). When starting fresh, your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you.
5. The agent's outputs should generally be trusted
6. Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent. Tell it how to verify its work if possible (e.g., relevant test commands).
7. If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement.
@@ -1,6 +1,5 @@
import { expect } from "bun:test"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Substitution } from "@opencode-ai/core/substitution"
import { Effect, Layer } from "effect"
import path from "path"
import { pathToFileURL } from "url"
@@ -12,7 +11,6 @@ import { RuntimeFlags } from "../../src/effect/runtime-flags"
import { Plugin } from "../../src/plugin"
import { AccountTest } from "../fake/account"
import { AuthTest } from "../fake/auth"
import { AuthWellKnownTest } from "../fake/auth-well-known"
import { NpmTest } from "../fake/npm"
import { ProviderTest } from "../fake/provider"
import { SkillTest } from "../fake/skill"
@@ -27,8 +25,6 @@ const pluginUrl = pathToFileURL(path.join(import.meta.dir, "..", "fixture", "age
const provider = ProviderTest.fake()
const configLayer = Config.layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(AuthWellKnownTest.empty),
Layer.provide(Substitution.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(AuthTest.empty),
Layer.provide(AccountTest.empty),
+183 -63
View File
@@ -8,7 +8,7 @@ import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import { InstanceRef } from "../../src/effect/instance-ref"
import type { InstanceContext } from "../../src/project/instance-context"
import { AuthWellKnown } from "@opencode-ai/core/auth-well-known"
import { Auth } from "../../src/auth"
import { Account } from "../../src/account/account"
import { AccessToken, AccountID, OrgID } from "../../src/account/schema"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
@@ -31,14 +31,16 @@ import { ProjectID } from "../../src/project/schema"
import { Filesystem } from "@/util/filesystem"
import { ConfigPlugin } from "@/config/plugin"
import { Npm } from "@opencode-ai/core/npm"
import { Substitution } from "@opencode-ai/core/substitution"
import { AuthWellKnownTest } from "../fake/auth-well-known"
const emptyAccount = Layer.mock(Account.Service)({
active: () => Effect.succeed(Option.none()),
activeOrg: () => Effect.succeed(Option.none()),
})
const emptyAuth = Layer.mock(Auth.Service)({
all: () => Effect.succeed({}),
})
const testFlock = EffectFlock.defaultLayer
const noopNpm = Layer.mock(Npm.Service)({
@@ -47,15 +49,11 @@ const noopNpm = Layer.mock(Npm.Service)({
which: () => Effect.succeed(Option.none()),
})
const runSubstitution = <A, E>(effect: Effect.Effect<A, E, Substitution.Service>) =>
Effect.runPromise(effect.pipe(Effect.provide(Substitution.defaultLayer)))
const layer = Config.layer.pipe(
Layer.provide(testFlock),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Substitution.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(AuthWellKnownTest.empty),
Layer.provide(emptyAuth),
Layer.provide(emptyAccount),
Layer.provideMerge(infra),
Layer.provide(noopNpm),
@@ -488,30 +486,6 @@ test("handles environment variable substitution", async () => {
}
})
test("environment variable substitution accepts an env overlay", async () => {
const originalEnv = process.env["TEST_VAR"]
delete process.env["TEST_VAR"]
try {
expect(
await runSubstitution(
Substitution.Service.use((substitution) =>
substitution.substitute({
text: "{env:TEST_VAR}",
type: "virtual",
dir: "/tmp",
source: "test",
env: { TEST_VAR: "overlay" },
}),
),
),
).toBe("overlay")
} finally {
if (originalEnv === undefined) delete process.env["TEST_VAR"]
else process.env["TEST_VAR"] = originalEnv
}
})
test("preserves env variables when adding $schema to config", async () => {
const originalEnv = process.env["PRESERVE_VAR"]
process.env["PRESERVE_VAR"] = "secret_value"
@@ -590,9 +564,8 @@ test("resolves env templates in account config with account token", async () =>
const layer = Config.layer.pipe(
Layer.provide(testFlock),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Substitution.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(AuthWellKnownTest.empty),
Layer.provide(emptyAuth),
Layer.provide(fakeAccount),
Layer.provideMerge(infra),
Layer.provide(noopNpm),
@@ -1102,9 +1075,8 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
const testLayer = Config.layer.pipe(
Layer.provide(testFlock),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Substitution.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(AuthWellKnownTest.empty),
Layer.provide(emptyAuth),
Layer.provide(emptyAccount),
Layer.provideMerge(infra),
Layer.provide(noopNpm),
@@ -1966,44 +1938,192 @@ test("local .opencode config can override MCP from project config", async () =>
})
test("project config overrides remote well-known config", async () => {
const fakeAuthWellKnown = Layer.mock(AuthWellKnown.Service)({
configs: () =>
Effect.succeed([
{
url: "https://example.com",
source: "https://example.com/.well-known/opencode",
dir: "https://example.com/.well-known",
content: {
mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: false } },
},
},
]),
const originalFetch = globalThis.fetch
let fetchedUrl: string | undefined
globalThis.fetch = mock((url: string | URL | Request) => {
const urlStr = url instanceof Request ? url.url : url instanceof URL ? url.href : url
if (urlStr.includes(".well-known/opencode")) {
fetchedUrl = urlStr
return Promise.resolve(
new Response(
JSON.stringify({
config: {
mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: false } },
},
}),
{ status: 200 },
),
)
}
return originalFetch(url)
}) as unknown as typeof fetch
const fakeAuth = Layer.mock(Auth.Service)({
all: () =>
Effect.succeed({
"https://example.com": new Auth.WellKnown({ type: "wellknown", key: "TEST_TOKEN", token: "test-token" }),
}),
})
const layer = Config.layer.pipe(
Layer.provide(testFlock),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Substitution.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(fakeAuthWellKnown),
Layer.provide(fakeAuth),
Layer.provide(emptyAccount),
Layer.provideMerge(infra),
Layer.provide(noopNpm),
)
await provideTmpdirInstance(
() =>
Config.Service.use((svc) =>
Effect.gen(function* () {
const config = yield* svc.get()
expect(config.mcp?.jira?.enabled).toBe(true)
}),
),
{
git: true,
config: { mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: true } } },
},
).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
try {
await provideTmpdirInstance(
() =>
Config.Service.use((svc) =>
Effect.gen(function* () {
const config = yield* svc.get()
expect(fetchedUrl).toBe("https://example.com/.well-known/opencode")
expect(config.mcp?.jira?.enabled).toBe(true)
}),
),
{
git: true,
config: { mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: true } } },
},
).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
} finally {
globalThis.fetch = originalFetch
}
})
test("wellknown URL with trailing slash is normalized", async () => {
const originalFetch = globalThis.fetch
let fetchedUrl: string | undefined
globalThis.fetch = mock((url: string | URL | Request) => {
const urlStr = url instanceof Request ? url.url : url instanceof URL ? url.href : url
if (urlStr.includes(".well-known/opencode")) {
fetchedUrl = urlStr
return Promise.resolve(
new Response(
JSON.stringify({
config: {
mcp: { slack: { type: "remote", url: "https://slack.example.com/mcp", enabled: true } },
},
}),
{ status: 200 },
),
)
}
return originalFetch(url)
}) as unknown as typeof fetch
const fakeAuth = Layer.mock(Auth.Service)({
all: () =>
Effect.succeed({
"https://example.com/": new Auth.WellKnown({ type: "wellknown", key: "TEST_TOKEN", token: "test-token" }),
}),
})
const layer = Config.layer.pipe(
Layer.provide(testFlock),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(fakeAuth),
Layer.provide(emptyAccount),
Layer.provideMerge(infra),
Layer.provide(noopNpm),
)
try {
await provideTmpdirInstance(
() =>
Config.Service.use((svc) =>
Effect.gen(function* () {
yield* svc.get()
expect(fetchedUrl).toBe("https://example.com/.well-known/opencode")
}),
),
{ git: true },
).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
} finally {
globalThis.fetch = originalFetch
}
})
test("wellknown remote_config supports templated env vars in headers", async () => {
const originalFetch = globalThis.fetch
const originalToken = process.env.TEST_TOKEN
let wellknownFetchedUrl: string | undefined
let remoteFetchedUrl: string | undefined
let remoteHeaders: HeadersInit | undefined
globalThis.fetch = mock((url: string | URL | Request, init?: RequestInit) => {
const urlStr = url instanceof Request ? url.url : url instanceof URL ? url.href : url
if (urlStr.includes(".well-known/opencode")) {
wellknownFetchedUrl = urlStr
return Promise.resolve(
new Response(
JSON.stringify({
remote_config: {
url: "https://config.example.com/opencode.json",
headers: {
Authorization: "Bearer {env:TEST_TOKEN}",
},
},
}),
{ status: 200 },
),
)
}
if (urlStr.includes("config.example.com")) {
remoteFetchedUrl = urlStr
remoteHeaders = init?.headers
return Promise.resolve(
new Response(
JSON.stringify({
mcp: { confluence: { type: "remote", url: "https://confluence.example.com/mcp", enabled: true } },
}),
{ status: 200 },
),
)
}
return originalFetch(url, init)
}) as unknown as typeof fetch
const fakeAuth = Layer.mock(Auth.Service)({
all: () =>
Effect.succeed({
"https://example.com": new Auth.WellKnown({ type: "wellknown", key: "TEST_TOKEN", token: "test-token" }),
}),
})
const layer = Config.layer.pipe(
Layer.provide(testFlock),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(fakeAuth),
Layer.provide(emptyAccount),
Layer.provideMerge(infra),
Layer.provide(noopNpm),
)
try {
await provideTmpdirInstance(
() =>
Config.Service.use((svc) =>
Effect.gen(function* () {
const config = yield* svc.get()
expect(wellknownFetchedUrl).toBe("https://example.com/.well-known/opencode")
expect(remoteFetchedUrl).toBe("https://config.example.com/opencode.json")
expect(remoteHeaders).toEqual({ Authorization: "Bearer test-token" })
expect(config.mcp?.confluence?.enabled).toBe(true)
}),
),
{ git: true },
).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
} finally {
globalThis.fetch = originalFetch
if (originalToken === undefined) delete process.env.TEST_TOKEN
else process.env.TEST_TOKEN = originalToken
}
})
describe("resolvePluginSpec", () => {
@@ -1,8 +0,0 @@
import { AuthWellKnown } from "@opencode-ai/core/auth-well-known"
import { Effect, Layer } from "effect"
export const AuthWellKnownTest = {
empty: Layer.mock(AuthWellKnown.Service, {
configs: () => Effect.succeed([]),
}),
}
+2 -33
View File
@@ -1,14 +1,13 @@
import { describe, expect, spyOn } from "bun:test"
import path from "path"
import { Deferred, Effect, Layer } from "effect"
import { Bus } from "@/bus"
import { Effect, Layer } from "effect"
import { Config } from "@/config/config"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { LSP } from "@/lsp/lsp"
import * as LSPServer from "@/lsp/server"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { provideTmpdirInstance } from "../fixture/fixture"
import { awaitWithTimeout, testEffect } from "../lib/effect"
import { testEffect } from "../lib/effect"
const it = testEffect(Layer.mergeAll(LSP.defaultLayer, CrossSpawnSpawner.defaultLayer))
const experimentalTyIt = testEffect(
@@ -17,7 +16,6 @@ const experimentalTyIt = testEffect(
CrossSpawnSpawner.defaultLayer,
),
)
const fakeServerPath = path.join(__dirname, "../fixture/lsp/fake-lsp-server.js")
const disabledDownloadIt = testEffect(
Layer.mergeAll(
LSP.layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(RuntimeFlags.layer({ disableLspDownload: true }))),
@@ -94,35 +92,6 @@ describe("lsp.spawn", () => {
),
)
it.live("publishes lsp.updated after custom LSP initialization", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const lsp = yield* LSP.Service
const updated = yield* Deferred.make<void>()
const unsubscribe = Bus.subscribe(LSP.Event.Updated, () =>
Effect.runSync(Deferred.succeed(updated, undefined)),
)
yield* Effect.addFinalizer(() => Effect.sync(unsubscribe))
const file = path.join(dir, "sample.repro")
yield* Effect.promise(() => Bun.write(file, "sample\n"))
yield* lsp.touchFile(file)
yield* awaitWithTimeout(Deferred.await(updated), "lsp.updated event was not published")
}),
{
config: {
lsp: {
fake: {
command: [process.execPath, fakeServerPath],
extensions: [".repro"],
},
},
},
},
),
)
it.live("would spawn builtin LSP for files inside instance when config object is provided", () =>
provideTmpdirInstance(
(dir) =>
@@ -3,7 +3,6 @@ import { Effect, Layer, Option } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import { Substitution } from "@opencode-ai/core/substitution"
import path from "path"
import { pathToFileURL } from "url"
import { Account } from "../../src/account/account"
@@ -17,7 +16,6 @@ import { ModelID, ProviderID } from "../../src/provider/schema"
import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { NpmTest } from "../fake/npm"
import { AuthWellKnownTest } from "../fake/auth-well-known"
const emptyAccount = Layer.mock(Account.Service)({
active: () => Effect.succeed(Option.none()),
@@ -29,8 +27,6 @@ const emptyAuth = Layer.mock(Auth.Service)({
const configLayer = Config.layer.pipe(
Layer.provide(EffectFlock.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(AuthWellKnownTest.empty),
Layer.provide(Substitution.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(emptyAuth),
Layer.provide(emptyAccount),
@@ -4,7 +4,6 @@ import { FetchHttpClient } from "effect/unstable/http"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import { Substitution } from "@opencode-ai/core/substitution"
import path from "path"
import { pathToFileURL } from "url"
import { Account } from "../../src/account/account"
@@ -26,7 +25,6 @@ import { SyncEvent } from "../../src/sync"
import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { NpmTest } from "../fake/npm"
import { AuthWellKnownTest } from "../fake/auth-well-known"
const emptyAccount = Layer.mock(Account.Service)({
active: () => Effect.succeed(Option.none()),
@@ -38,8 +36,6 @@ const emptyAuth = Layer.mock(Auth.Service)({
const configLayer = Config.layer.pipe(
Layer.provide(EffectFlock.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(AuthWellKnownTest.empty),
Layer.provide(Substitution.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(emptyAuth),
Layer.provide(emptyAccount),
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.15.4",
"version": "1.15.3",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.15.4",
"version": "1.15.3",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.15.4",
"version": "1.15.3",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.15.4",
"version": "1.15.3",
"type": "module",
"license": "MIT",
"exports": {
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode-ai/web",
"type": "module",
"license": "MIT",
"version": "1.15.4",
"version": "1.15.3",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.15.4",
"version": "1.15.3",
"publisher": "sst-dev",
"repository": {
"type": "git",