mirror of
https://fastgit.cc/https://github.com/anomalyco/opencode
synced 2026-05-03 23:31:41 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
006a05abe8 |
1
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
1
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@@ -1,5 +1,6 @@
|
||||
name: Bug report
|
||||
description: Report an issue that should be fixed
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
|
||||
1
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
1
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
@@ -1,5 +1,6 @@
|
||||
name: 🚀 Feature Request
|
||||
description: Suggest an idea, feature, or enhancement
|
||||
labels: [discussion]
|
||||
title: "[FEATURE]:"
|
||||
|
||||
body:
|
||||
|
||||
1
.github/ISSUE_TEMPLATE/question.yml
vendored
1
.github/ISSUE_TEMPLATE/question.yml
vendored
@@ -1,5 +1,6 @@
|
||||
name: Question
|
||||
description: Ask a question
|
||||
labels: ["question"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: question
|
||||
|
||||
1
.github/TEAM_MEMBERS
vendored
1
.github/TEAM_MEMBERS
vendored
@@ -11,5 +11,6 @@ MrMushrooooom
|
||||
nexxeln
|
||||
R44VC0RP
|
||||
rekram1-node
|
||||
RhysSullivan
|
||||
thdxr
|
||||
simonklee
|
||||
|
||||
41
.github/VOUCHED.td
vendored
Normal file
41
.github/VOUCHED.td
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
# Vouched contributors for this project.
|
||||
#
|
||||
# See https://github.com/mitchellh/vouch for details.
|
||||
#
|
||||
# Syntax:
|
||||
# - One handle per line (without @), sorted alphabetically.
|
||||
# - Optional platform prefix: platform:username (e.g., github:user).
|
||||
# - Denounce with minus prefix: -username or -platform:username.
|
||||
# - Optional details after a space following the handle.
|
||||
adamdotdevin
|
||||
-agusbasari29 AI PR slop
|
||||
ariane-emory
|
||||
-atharvau AI review spamming literally every PR
|
||||
-borealbytes
|
||||
-carycooper777
|
||||
-danieljoshuanazareth
|
||||
-danieljoshuanazareth
|
||||
-davidbernat looks to be a clawdbot that spams team and sends super weird emails, doesnt appear to be a real person
|
||||
dmtrkovalenko
|
||||
edemaine
|
||||
fahreddinozcan
|
||||
-florianleibert
|
||||
fwang
|
||||
iamdavidhill
|
||||
jayair
|
||||
kitlangton
|
||||
kommander
|
||||
-opencode2026
|
||||
-opencodeengineer bot that spams issues
|
||||
r44vc0rp
|
||||
rekram1-node
|
||||
-ricardo-m-l
|
||||
-robinmordasiewicz
|
||||
rubdos
|
||||
-saisharan0103 spamming ai prs
|
||||
shantur
|
||||
simonklee
|
||||
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
||||
-terisuke
|
||||
thdxr
|
||||
-toastythebot
|
||||
170
.github/workflows/daily-issues-recap.yml
vendored
Normal file
170
.github/workflows/daily-issues-recap.yml
vendored
Normal file
@@ -0,0 +1,170 @@
|
||||
name: daily-issues-recap
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run at 6 PM EST (23:00 UTC, or 22:00 UTC during daylight saving)
|
||||
- cron: "0 23 * * *"
|
||||
workflow_dispatch: # Allow manual trigger for testing
|
||||
|
||||
jobs:
|
||||
daily-recap:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
permissions:
|
||||
contents: read
|
||||
issues: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Install opencode
|
||||
run: curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
- name: Generate daily issues recap
|
||||
id: recap
|
||||
env:
|
||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
OPENCODE_PERMISSION: |
|
||||
{
|
||||
"bash": {
|
||||
"*": "deny",
|
||||
"gh issue*": "allow",
|
||||
"gh search*": "allow"
|
||||
},
|
||||
"webfetch": "deny",
|
||||
"edit": "deny",
|
||||
"write": "deny"
|
||||
}
|
||||
run: |
|
||||
# Get today's date range
|
||||
TODAY=$(date -u +%Y-%m-%d)
|
||||
|
||||
opencode run -m opencode/claude-sonnet-4-5 "Generate a daily issues recap for the OpenCode repository.
|
||||
|
||||
TODAY'S DATE: ${TODAY}
|
||||
|
||||
STEP 1: Gather today's issues
|
||||
Search for all OPEN issues created today (${TODAY}) using:
|
||||
gh issue list --repo ${{ github.repository }} --state open --search \"created:${TODAY}\" --json number,title,body,labels,state,comments,createdAt,author --limit 500
|
||||
|
||||
IMPORTANT: EXCLUDE all issues authored by Anomaly team members. Filter out issues where the author login matches ANY of these:
|
||||
adamdotdevin, Brendonovich, fwang, Hona, iamdavidhill, jayair, kitlangton, kommander, MrMushrooooom, R44VC0RP, rekram1-node, thdxr
|
||||
This recap is specifically for COMMUNITY (external) issues only.
|
||||
|
||||
STEP 2: Analyze and categorize
|
||||
For each issue created today, categorize it:
|
||||
|
||||
**Severity Assessment:**
|
||||
- CRITICAL: Crashes, data loss, security issues, blocks major functionality
|
||||
- HIGH: Significant bugs affecting many users, important features broken
|
||||
- MEDIUM: Bugs with workarounds, minor features broken
|
||||
- LOW: Minor issues, cosmetic, nice-to-haves
|
||||
|
||||
**Activity Assessment:**
|
||||
- Note issues with high comment counts or engagement
|
||||
- Note issues from repeat reporters (check if author has filed before)
|
||||
|
||||
STEP 3: Cross-reference with existing issues
|
||||
For issues that seem like feature requests or recurring bugs:
|
||||
- Search for similar older issues to identify patterns
|
||||
- Note if this is a frequently requested feature
|
||||
- Identify any issues that are duplicates of long-standing requests
|
||||
|
||||
STEP 4: Generate the recap
|
||||
Create a structured recap with these sections:
|
||||
|
||||
===DISCORD_START===
|
||||
**Daily Issues Recap - ${TODAY}**
|
||||
|
||||
**Summary Stats**
|
||||
- Total issues opened today: [count]
|
||||
- By category: [bugs/features/questions]
|
||||
|
||||
**Critical/High Priority Issues**
|
||||
[List any CRITICAL or HIGH severity issues with brief descriptions and issue numbers]
|
||||
|
||||
**Most Active/Discussed**
|
||||
[Issues with significant engagement or from active community members]
|
||||
|
||||
**Trending Topics**
|
||||
[Patterns noticed - e.g., 'Multiple reports about X', 'Continued interest in Y feature']
|
||||
|
||||
**Duplicates & Related**
|
||||
[Issues that relate to existing open issues]
|
||||
===DISCORD_END===
|
||||
|
||||
STEP 5: Format for Discord
|
||||
Format the recap as a Discord-compatible message:
|
||||
- Use Discord markdown (**, __, etc.)
|
||||
- BE EXTREMELY CONCISE - this is an EOD summary, not a detailed report
|
||||
- Use hyperlinked issue numbers with suppressed embeds: [#1234](<https://github.com/${{ github.repository }}/issues/1234>)
|
||||
- Group related issues on single lines where possible
|
||||
- Add emoji sparingly for critical items only
|
||||
- HARD LIMIT: Keep under 1800 characters total
|
||||
- Skip sections that have nothing notable (e.g., if no critical issues, omit that section)
|
||||
- Prioritize signal over completeness - only surface what matters
|
||||
|
||||
OUTPUT: Output ONLY the content between ===DISCORD_START=== and ===DISCORD_END=== markers. Include the markers so I can extract it." > /tmp/recap_raw.txt
|
||||
|
||||
# Extract only the Discord message between markers
|
||||
sed -n '/===DISCORD_START===/,/===DISCORD_END===/p' /tmp/recap_raw.txt | grep -v '===DISCORD' > /tmp/recap.txt
|
||||
|
||||
echo "recap_file=/tmp/recap.txt" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Post to Discord
|
||||
env:
|
||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_ISSUES_WEBHOOK_URL }}
|
||||
run: |
|
||||
if [ -z "$DISCORD_WEBHOOK_URL" ]; then
|
||||
echo "Warning: DISCORD_ISSUES_WEBHOOK_URL secret not set, skipping Discord post"
|
||||
cat /tmp/recap.txt
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Read the recap
|
||||
RECAP_RAW=$(cat /tmp/recap.txt)
|
||||
RECAP_LENGTH=${#RECAP_RAW}
|
||||
|
||||
echo "Recap length: ${RECAP_LENGTH} chars"
|
||||
|
||||
# Function to post a message to Discord
|
||||
post_to_discord() {
|
||||
local msg="$1"
|
||||
local content=$(echo "$msg" | jq -Rs '.')
|
||||
curl -s -H "Content-Type: application/json" \
|
||||
-X POST \
|
||||
-d "{\"content\": ${content}}" \
|
||||
"$DISCORD_WEBHOOK_URL"
|
||||
sleep 1
|
||||
}
|
||||
|
||||
# If under limit, send as single message
|
||||
if [ "$RECAP_LENGTH" -le 1950 ]; then
|
||||
post_to_discord "$RECAP_RAW"
|
||||
else
|
||||
echo "Splitting into multiple messages..."
|
||||
remaining="$RECAP_RAW"
|
||||
while [ ${#remaining} -gt 0 ]; do
|
||||
if [ ${#remaining} -le 1950 ]; then
|
||||
post_to_discord "$remaining"
|
||||
break
|
||||
else
|
||||
chunk="${remaining:0:1900}"
|
||||
last_newline=$(echo "$chunk" | grep -bo $'\n' | tail -1 | cut -d: -f1)
|
||||
if [ -n "$last_newline" ] && [ "$last_newline" -gt 500 ]; then
|
||||
chunk="${remaining:0:$last_newline}"
|
||||
remaining="${remaining:$((last_newline+1))}"
|
||||
else
|
||||
chunk="${remaining:0:1900}"
|
||||
remaining="${remaining:1900}"
|
||||
fi
|
||||
post_to_discord "$chunk"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
echo "Posted daily recap to Discord"
|
||||
173
.github/workflows/daily-pr-recap.yml
vendored
Normal file
173
.github/workflows/daily-pr-recap.yml
vendored
Normal file
@@ -0,0 +1,173 @@
|
||||
name: daily-pr-recap
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run at 5pm EST (22:00 UTC, or 21:00 UTC during daylight saving)
|
||||
- cron: "0 22 * * *"
|
||||
workflow_dispatch: # Allow manual trigger for testing
|
||||
|
||||
jobs:
|
||||
pr-recap:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Install opencode
|
||||
run: curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
- name: Generate daily PR recap
|
||||
id: recap
|
||||
env:
|
||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
OPENCODE_PERMISSION: |
|
||||
{
|
||||
"bash": {
|
||||
"*": "deny",
|
||||
"gh pr*": "allow",
|
||||
"gh search*": "allow"
|
||||
},
|
||||
"webfetch": "deny",
|
||||
"edit": "deny",
|
||||
"write": "deny"
|
||||
}
|
||||
run: |
|
||||
TODAY=$(date -u +%Y-%m-%d)
|
||||
|
||||
opencode run -m opencode/claude-sonnet-4-5 "Generate a daily PR activity recap for the OpenCode repository.
|
||||
|
||||
TODAY'S DATE: ${TODAY}
|
||||
|
||||
STEP 1: Gather PR data
|
||||
Run these commands to gather PR information. ONLY include OPEN PRs created or updated TODAY (${TODAY}):
|
||||
|
||||
# Open PRs created today
|
||||
gh pr list --repo ${{ github.repository }} --state open --search \"created:${TODAY}\" --json number,title,author,labels,createdAt,updatedAt,reviewDecision,isDraft,additions,deletions --limit 100
|
||||
|
||||
# Open PRs with activity today (updated today)
|
||||
gh pr list --repo ${{ github.repository }} --state open --search \"updated:${TODAY}\" --json number,title,author,labels,createdAt,updatedAt,reviewDecision,isDraft,additions,deletions --limit 100
|
||||
|
||||
IMPORTANT: EXCLUDE all PRs authored by Anomaly team members. Filter out PRs where the author login matches ANY of these:
|
||||
adamdotdevin, Brendonovich, fwang, Hona, iamdavidhill, jayair, kitlangton, kommander, MrMushrooooom, R44VC0RP, rekram1-node, thdxr
|
||||
This recap is specifically for COMMUNITY (external) contributions only.
|
||||
|
||||
|
||||
|
||||
STEP 2: For high-activity PRs, check comment counts
|
||||
For promising PRs, run:
|
||||
gh pr view [NUMBER] --repo ${{ github.repository }} --json comments --jq '[.comments[] | select(.author.login != \"copilot-pull-request-reviewer\" and .author.login != \"github-actions\")] | length'
|
||||
|
||||
IMPORTANT: When counting comments/activity, EXCLUDE these bot accounts:
|
||||
- copilot-pull-request-reviewer
|
||||
- github-actions
|
||||
|
||||
STEP 3: Identify what matters (ONLY from today's PRs)
|
||||
|
||||
**Bug Fixes From Today:**
|
||||
- PRs with 'fix' or 'bug' in title created/updated today
|
||||
- Small bug fixes (< 100 lines changed) that are easy to review
|
||||
- Bug fixes from community contributors
|
||||
|
||||
**High Activity Today:**
|
||||
- PRs with significant human comments today (excluding bots listed above)
|
||||
- PRs with back-and-forth discussion today
|
||||
|
||||
**Quick Wins:**
|
||||
- Small PRs (< 50 lines) that are approved or nearly approved
|
||||
- PRs that just need a final review
|
||||
|
||||
STEP 4: Generate the recap
|
||||
Create a structured recap:
|
||||
|
||||
===DISCORD_START===
|
||||
**Daily PR Recap - ${TODAY}**
|
||||
|
||||
**New PRs Today**
|
||||
[PRs opened today - group by type: bug fixes, features, etc.]
|
||||
|
||||
**Active PRs Today**
|
||||
[PRs with activity/updates today - significant discussion]
|
||||
|
||||
**Quick Wins**
|
||||
[Small PRs ready to merge]
|
||||
===DISCORD_END===
|
||||
|
||||
STEP 5: Format for Discord
|
||||
- Use Discord markdown (**, __, etc.)
|
||||
- BE EXTREMELY CONCISE - surface what we might miss
|
||||
- Use hyperlinked PR numbers with suppressed embeds: [#1234](<https://github.com/${{ github.repository }}/pull/1234>)
|
||||
- Include PR author: [#1234](<url>) (@author)
|
||||
- For bug fixes, add brief description of what it fixes
|
||||
- Show line count for quick wins: \"(+15/-3 lines)\"
|
||||
- HARD LIMIT: Keep under 1800 characters total
|
||||
- Skip empty sections
|
||||
- Focus on PRs that need human eyes
|
||||
|
||||
OUTPUT: Output ONLY the content between ===DISCORD_START=== and ===DISCORD_END=== markers. Include the markers so I can extract it." > /tmp/pr_recap_raw.txt
|
||||
|
||||
# Extract only the Discord message between markers
|
||||
sed -n '/===DISCORD_START===/,/===DISCORD_END===/p' /tmp/pr_recap_raw.txt | grep -v '===DISCORD' > /tmp/pr_recap.txt
|
||||
|
||||
echo "recap_file=/tmp/pr_recap.txt" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Post to Discord
|
||||
env:
|
||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_ISSUES_WEBHOOK_URL }}
|
||||
run: |
|
||||
if [ -z "$DISCORD_WEBHOOK_URL" ]; then
|
||||
echo "Warning: DISCORD_ISSUES_WEBHOOK_URL secret not set, skipping Discord post"
|
||||
cat /tmp/pr_recap.txt
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Read the recap
|
||||
RECAP_RAW=$(cat /tmp/pr_recap.txt)
|
||||
RECAP_LENGTH=${#RECAP_RAW}
|
||||
|
||||
echo "Recap length: ${RECAP_LENGTH} chars"
|
||||
|
||||
# Function to post a message to Discord
|
||||
post_to_discord() {
|
||||
local msg="$1"
|
||||
local content=$(echo "$msg" | jq -Rs '.')
|
||||
curl -s -H "Content-Type: application/json" \
|
||||
-X POST \
|
||||
-d "{\"content\": ${content}}" \
|
||||
"$DISCORD_WEBHOOK_URL"
|
||||
sleep 1
|
||||
}
|
||||
|
||||
# If under limit, send as single message
|
||||
if [ "$RECAP_LENGTH" -le 1950 ]; then
|
||||
post_to_discord "$RECAP_RAW"
|
||||
else
|
||||
echo "Splitting into multiple messages..."
|
||||
remaining="$RECAP_RAW"
|
||||
while [ ${#remaining} -gt 0 ]; do
|
||||
if [ ${#remaining} -le 1950 ]; then
|
||||
post_to_discord "$remaining"
|
||||
break
|
||||
else
|
||||
chunk="${remaining:0:1900}"
|
||||
last_newline=$(echo "$chunk" | grep -bo $'\n' | tail -1 | cut -d: -f1)
|
||||
if [ -n "$last_newline" ] && [ "$last_newline" -gt 500 ]; then
|
||||
chunk="${remaining:0:$last_newline}"
|
||||
remaining="${remaining:$((last_newline+1))}"
|
||||
else
|
||||
chunk="${remaining:0:1900}"
|
||||
remaining="${remaining:1900}"
|
||||
fi
|
||||
post_to_discord "$chunk"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
echo "Posted daily PR recap to Discord"
|
||||
219
.github/workflows/publish.yml
vendored
219
.github/workflows/publish.yml
vendored
@@ -209,6 +209,182 @@ jobs:
|
||||
packages/opencode/dist/opencode-windows-x64
|
||||
packages/opencode/dist/opencode-windows-x64-baseline
|
||||
|
||||
build-tauri:
|
||||
needs:
|
||||
- build-cli
|
||||
- version
|
||||
continue-on-error: false
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
|
||||
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
|
||||
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
settings:
|
||||
- host: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
- host: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
# github-hosted: blacksmith lacks ARM64 MSVC cross-compilation toolchain
|
||||
- host: windows-2025
|
||||
target: aarch64-pc-windows-msvc
|
||||
- host: blacksmith-4vcpu-windows-2025
|
||||
target: x86_64-pc-windows-msvc
|
||||
- host: blacksmith-4vcpu-ubuntu-2404
|
||||
target: x86_64-unknown-linux-gnu
|
||||
- host: blacksmith-8vcpu-ubuntu-2404-arm
|
||||
target: aarch64-unknown-linux-gnu
|
||||
runs-on: ${{ matrix.settings.host }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-tags: true
|
||||
|
||||
- uses: apple-actions/import-codesign-certs@v2
|
||||
if: ${{ runner.os == 'macOS' }}
|
||||
with:
|
||||
keychain: build
|
||||
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
|
||||
- name: Verify Certificate
|
||||
if: ${{ runner.os == 'macOS' }}
|
||||
run: |
|
||||
CERT_INFO=$(security find-identity -v -p codesigning build.keychain | grep "Developer ID Application")
|
||||
CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}')
|
||||
echo "CERT_ID=$CERT_ID" >> $GITHUB_ENV
|
||||
echo "Certificate imported."
|
||||
|
||||
- name: Setup Apple API Key
|
||||
if: ${{ runner.os == 'macOS' }}
|
||||
run: |
|
||||
echo "${{ secrets.APPLE_API_KEY_PATH }}" > $RUNNER_TEMP/apple-api-key.p8
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Azure login
|
||||
if: runner.os == 'Windows'
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ env.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ env.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
- name: Cache apt packages
|
||||
if: contains(matrix.settings.host, 'ubuntu')
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/apt-cache
|
||||
key: ${{ runner.os }}-${{ matrix.settings.target }}-apt-${{ hashFiles('.github/workflows/publish.yml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ matrix.settings.target }}-apt-
|
||||
|
||||
- name: install dependencies (ubuntu only)
|
||||
if: contains(matrix.settings.host, 'ubuntu')
|
||||
run: |
|
||||
mkdir -p ~/apt-cache && chmod -R a+rw ~/apt-cache
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends -o dir::cache::archives="$HOME/apt-cache" libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||
sudo chmod -R a+rw ~/apt-cache
|
||||
|
||||
- name: install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.settings.target }}
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: packages/desktop/src-tauri
|
||||
shared-key: ${{ matrix.settings.target }}
|
||||
|
||||
- name: Prepare
|
||||
run: |
|
||||
cd packages/desktop
|
||||
bun ./scripts/prepare.ts
|
||||
env:
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
|
||||
OPENCODE_CLI_ARTIFACT: ${{ (runner.os == 'Windows' && 'opencode-cli-windows') || 'opencode-cli' }}
|
||||
RUST_TARGET: ${{ matrix.settings.target }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
|
||||
- name: Resolve tauri portable SHA
|
||||
if: contains(matrix.settings.host, 'ubuntu')
|
||||
run: echo "TAURI_PORTABLE_SHA=$(git ls-remote https://github.com/tauri-apps/tauri.git refs/heads/feat/truly-portable-appimage | cut -f1)" >> "$GITHUB_ENV"
|
||||
|
||||
# Fixes AppImage build issues, can be removed when https://github.com/tauri-apps/tauri/pull/12491 is released
|
||||
- name: Install tauri-cli from portable appimage branch
|
||||
uses: taiki-e/cache-cargo-install-action@v3
|
||||
if: contains(matrix.settings.host, 'ubuntu')
|
||||
with:
|
||||
tool: tauri-cli
|
||||
git: https://github.com/tauri-apps/tauri
|
||||
# branch: feat/truly-portable-appimage
|
||||
rev: ${{ env.TAURI_PORTABLE_SHA }}
|
||||
|
||||
- name: Show tauri-cli version
|
||||
if: contains(matrix.settings.host, 'ubuntu')
|
||||
run: cargo tauri --version
|
||||
|
||||
- name: Setup git committer
|
||||
id: committer
|
||||
uses: ./.github/actions/setup-git-committer
|
||||
with:
|
||||
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
|
||||
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
||||
|
||||
- name: Build and upload artifacts
|
||||
uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a
|
||||
timeout-minutes: 60
|
||||
with:
|
||||
projectPath: packages/desktop
|
||||
uploadWorkflowArtifacts: true
|
||||
tauriScript: ${{ (contains(matrix.settings.host, 'ubuntu') && 'cargo tauri') || '' }}
|
||||
args: --target ${{ matrix.settings.target }} --config ${{ (github.ref_name == 'beta' && './src-tauri/tauri.beta.conf.json') || './src-tauri/tauri.prod.conf.json' }} --verbose
|
||||
updaterJsonPreferNsis: true
|
||||
releaseId: ${{ needs.version.outputs.release }}
|
||||
tagName: ${{ needs.version.outputs.tag }}
|
||||
releaseDraft: true
|
||||
releaseAssetNamePattern: opencode-desktop-[platform]-[arch][ext]
|
||||
repo: ${{ (github.ref_name == 'beta' && 'opencode-beta') || '' }}
|
||||
releaseCommitish: ${{ github.sha }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
|
||||
TAURI_BUNDLER_NEW_APPIMAGE_FORMAT: true
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ env.CERT_ID }}
|
||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
||||
APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
|
||||
|
||||
- name: Verify signed Windows desktop artifacts
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$files = @(
|
||||
"${{ github.workspace }}\packages\desktop\src-tauri\sidecars\opencode-cli-${{ matrix.settings.target }}.exe"
|
||||
)
|
||||
$files += Get-ChildItem "${{ github.workspace }}\packages\desktop\src-tauri\target\${{ matrix.settings.target }}\release\bundle\nsis\*.exe" | Select-Object -ExpandProperty FullName
|
||||
|
||||
foreach ($file in $files) {
|
||||
$sig = Get-AuthenticodeSignature $file
|
||||
if ($sig.Status -ne "Valid") {
|
||||
throw "Invalid signature for ${file}: $($sig.Status)"
|
||||
}
|
||||
}
|
||||
|
||||
build-electron:
|
||||
needs:
|
||||
- build-cli
|
||||
@@ -348,30 +524,6 @@ jobs:
|
||||
env:
|
||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||
|
||||
- name: Create and upload macOS .app.tar.gz
|
||||
if: runner.os == 'macOS' && needs.version.outputs.release
|
||||
working-directory: packages/desktop-electron/dist
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
||||
run: |
|
||||
if [[ "${{ matrix.settings.target }}" == "x86_64-apple-darwin" ]]; then
|
||||
APP_DIR="mac"
|
||||
OUT_NAME="opencode-desktop-mac-x64.app.tar.gz"
|
||||
elif [[ "${{ matrix.settings.target }}" == "aarch64-apple-darwin" ]]; then
|
||||
APP_DIR="mac-arm64"
|
||||
OUT_NAME="opencode-desktop-mac-arm64.app.tar.gz"
|
||||
else
|
||||
echo "Unknown macOS target: ${{ matrix.settings.target }}"
|
||||
exit 1
|
||||
fi
|
||||
APP_PATH=$(find "$APP_DIR" -maxdepth 1 -name "*.app" -type d | head -1)
|
||||
if [ -z "$APP_PATH" ]; then
|
||||
echo "No .app bundle found in $APP_DIR"
|
||||
exit 1
|
||||
fi
|
||||
tar -czf "$OUT_NAME" -C "$(dirname "$APP_PATH")" "$(basename "$APP_PATH")"
|
||||
gh release upload "v${{ needs.version.outputs.version }}" "$OUT_NAME" --clobber --repo "${{ needs.version.outputs.repo }}"
|
||||
|
||||
- name: Verify signed Windows Electron artifacts
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
@@ -390,7 +542,7 @@ jobs:
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: opencode-desktop-${{ matrix.settings.target }}
|
||||
name: opencode-electron-${{ matrix.settings.target }}
|
||||
path: packages/desktop-electron/dist/*
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
@@ -404,6 +556,7 @@ jobs:
|
||||
- version
|
||||
- build-cli
|
||||
- sign-cli-windows
|
||||
- build-tauri
|
||||
- build-electron
|
||||
if: always() && !failure() && !cancelled()
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
@@ -430,6 +583,13 @@ jobs:
|
||||
node-version: "24"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Setup git committer
|
||||
id: committer
|
||||
uses: ./.github/actions/setup-git-committer
|
||||
with:
|
||||
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
|
||||
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: opencode-cli
|
||||
@@ -451,13 +611,6 @@ jobs:
|
||||
pattern: latest-yml-*
|
||||
path: /tmp/latest-yml
|
||||
|
||||
- name: Setup git committer
|
||||
id: committer
|
||||
uses: ./.github/actions/setup-git-committer
|
||||
with:
|
||||
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
|
||||
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
||||
|
||||
- name: Cache apt packages (AUR)
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
@@ -486,5 +639,3 @@ jobs:
|
||||
GH_REPO: ${{ needs.version.outputs.repo }}
|
||||
NPM_CONFIG_PROVENANCE: false
|
||||
LATEST_YML_DIR: /tmp/latest-yml
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
|
||||
116
.github/workflows/vouch-check-issue.yml
vendored
Normal file
116
.github/workflows/vouch-check-issue.yml
vendored
Normal file
@@ -0,0 +1,116 @@
|
||||
name: vouch-check-issue
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check if issue author is denounced
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const author = context.payload.issue.user.login;
|
||||
const issueNumber = context.payload.issue.number;
|
||||
|
||||
// Skip bots
|
||||
if (author.endsWith('[bot]')) {
|
||||
core.info(`Skipping bot: ${author}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Read the VOUCHED.td file via API (no checkout needed)
|
||||
let content;
|
||||
try {
|
||||
const response = await github.rest.repos.getContent({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
path: '.github/VOUCHED.td',
|
||||
});
|
||||
content = Buffer.from(response.data.content, 'base64').toString('utf-8');
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
core.info('No .github/VOUCHED.td file found, skipping check.');
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Parse the .td file for vouched and denounced users
|
||||
const vouched = new Set();
|
||||
const denounced = new Map();
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
|
||||
const isDenounced = trimmed.startsWith('-');
|
||||
const rest = isDenounced ? trimmed.slice(1).trim() : trimmed;
|
||||
if (!rest) continue;
|
||||
|
||||
const spaceIdx = rest.indexOf(' ');
|
||||
const handle = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx);
|
||||
const reason = spaceIdx === -1 ? null : rest.slice(spaceIdx + 1).trim();
|
||||
|
||||
// Handle platform:username or bare username
|
||||
// Only match bare usernames or github: prefix (skip other platforms)
|
||||
const colonIdx = handle.indexOf(':');
|
||||
if (colonIdx !== -1) {
|
||||
const platform = handle.slice(0, colonIdx).toLowerCase();
|
||||
if (platform !== 'github') continue;
|
||||
}
|
||||
const username = colonIdx === -1 ? handle : handle.slice(colonIdx + 1);
|
||||
if (!username) continue;
|
||||
|
||||
if (isDenounced) {
|
||||
denounced.set(username.toLowerCase(), reason);
|
||||
continue;
|
||||
}
|
||||
|
||||
vouched.add(username.toLowerCase());
|
||||
}
|
||||
|
||||
// Check if the author is denounced
|
||||
const reason = denounced.get(author.toLowerCase());
|
||||
if (reason !== undefined) {
|
||||
// Author is denounced — close the issue
|
||||
const body = 'This issue has been automatically closed.';
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
body,
|
||||
});
|
||||
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
state: 'closed',
|
||||
state_reason: 'not_planned',
|
||||
});
|
||||
|
||||
core.info(`Closed issue #${issueNumber} from denounced user ${author}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Author is positively vouched — add label
|
||||
if (!vouched.has(author.toLowerCase())) {
|
||||
core.info(`User ${author} is not denounced or vouched. Allowing issue.`);
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
labels: ['Vouched'],
|
||||
});
|
||||
|
||||
core.info(`Added vouched label to issue #${issueNumber} from ${author}`);
|
||||
114
.github/workflows/vouch-check-pr.yml
vendored
Normal file
114
.github/workflows/vouch-check-pr.yml
vendored
Normal file
@@ -0,0 +1,114 @@
|
||||
name: vouch-check-pr
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check if PR author is denounced
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const author = context.payload.pull_request.user.login;
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
|
||||
// Skip bots
|
||||
if (author.endsWith('[bot]')) {
|
||||
core.info(`Skipping bot: ${author}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Read the VOUCHED.td file via API (no checkout needed)
|
||||
let content;
|
||||
try {
|
||||
const response = await github.rest.repos.getContent({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
path: '.github/VOUCHED.td',
|
||||
});
|
||||
content = Buffer.from(response.data.content, 'base64').toString('utf-8');
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
core.info('No .github/VOUCHED.td file found, skipping check.');
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Parse the .td file for vouched and denounced users
|
||||
const vouched = new Set();
|
||||
const denounced = new Map();
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
|
||||
const isDenounced = trimmed.startsWith('-');
|
||||
const rest = isDenounced ? trimmed.slice(1).trim() : trimmed;
|
||||
if (!rest) continue;
|
||||
|
||||
const spaceIdx = rest.indexOf(' ');
|
||||
const handle = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx);
|
||||
const reason = spaceIdx === -1 ? null : rest.slice(spaceIdx + 1).trim();
|
||||
|
||||
// Handle platform:username or bare username
|
||||
// Only match bare usernames or github: prefix (skip other platforms)
|
||||
const colonIdx = handle.indexOf(':');
|
||||
if (colonIdx !== -1) {
|
||||
const platform = handle.slice(0, colonIdx).toLowerCase();
|
||||
if (platform !== 'github') continue;
|
||||
}
|
||||
const username = colonIdx === -1 ? handle : handle.slice(colonIdx + 1);
|
||||
if (!username) continue;
|
||||
|
||||
if (isDenounced) {
|
||||
denounced.set(username.toLowerCase(), reason);
|
||||
continue;
|
||||
}
|
||||
|
||||
vouched.add(username.toLowerCase());
|
||||
}
|
||||
|
||||
// Check if the author is denounced
|
||||
const reason = denounced.get(author.toLowerCase());
|
||||
if (reason !== undefined) {
|
||||
// Author is denounced — close the PR
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
body: 'This pull request has been automatically closed.',
|
||||
});
|
||||
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
state: 'closed',
|
||||
});
|
||||
|
||||
core.info(`Closed PR #${prNumber} from denounced user ${author}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Author is positively vouched — add label
|
||||
if (!vouched.has(author.toLowerCase())) {
|
||||
core.info(`User ${author} is not denounced or vouched. Allowing PR.`);
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
labels: ['Vouched'],
|
||||
});
|
||||
|
||||
core.info(`Added vouched label to PR #${prNumber} from ${author}`);
|
||||
38
.github/workflows/vouch-manage-by-issue.yml
vendored
Normal file
38
.github/workflows/vouch-manage-by-issue.yml
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
name: vouch-manage-by-issue
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
concurrency:
|
||||
group: vouch-manage
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
manage:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup git committer
|
||||
id: committer
|
||||
uses: ./.github/actions/setup-git-committer
|
||||
with:
|
||||
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
|
||||
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
||||
|
||||
- uses: mitchellh/vouch/action/manage-by-issue@main
|
||||
with:
|
||||
issue-id: ${{ github.event.issue.number }}
|
||||
comment-id: ${{ github.event.comment.id }}
|
||||
roles: admin,maintain,write
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
mode: primary
|
||||
hidden: true
|
||||
model: opencode/gpt-5.4-nano
|
||||
model: opencode/minimax-m2.5
|
||||
color: "#44BA81"
|
||||
tools:
|
||||
"*": false
|
||||
@@ -14,30 +14,127 @@ Use your github-triage tool to triage issues.
|
||||
|
||||
This file is the source of truth for ownership/routing rules.
|
||||
|
||||
Assign issues by choosing the team with the strongest overlap. The github-triage tool will assign a random member from that team.
|
||||
## Labels
|
||||
|
||||
Do not add labels to issues. Only assign an owner.
|
||||
### windows
|
||||
|
||||
When calling github-triage, pass one of these team values: tui, desktop_web, core, inference, windows.
|
||||
Use for any issue that mentions Windows (the OS). Be sure they are saying that they are on Windows.
|
||||
|
||||
## Teams
|
||||
- Use if they mention WSL too
|
||||
|
||||
### TUI
|
||||
#### perf
|
||||
|
||||
Terminal UI issues, including rendering, keybindings, scrolling, terminal compatibility, SSH behavior, crashes in the TUI, and low-level TUI performance.
|
||||
Performance-related issues:
|
||||
|
||||
### Desktop / Web
|
||||
- Slow performance
|
||||
- High RAM usage
|
||||
- High CPU usage
|
||||
|
||||
Desktop application and browser-based app issues, including `opencode web`, desktop-specific UI behavior, packaging, and web view problems.
|
||||
**Only** add if it's likely a RAM or CPU issue. **Do not** add for LLM slowness.
|
||||
|
||||
### Core
|
||||
#### desktop
|
||||
|
||||
Core opencode server and harness issues, including sqlite, snapshots, memory, API behavior, agent context construction, tool execution, provider integrations, model behavior, documentation, and larger architectural features.
|
||||
Desktop app issues:
|
||||
|
||||
### Inference
|
||||
- `opencode web` command
|
||||
- The desktop app itself
|
||||
|
||||
OpenCode Zen, OpenCode Go, and billing issues.
|
||||
**Only** add if it's specifically about the Desktop application or `opencode web` view. **Do not** add for terminal, TUI, or general opencode issues.
|
||||
|
||||
### Windows
|
||||
#### nix
|
||||
|
||||
Windows-specific issues, including native Windows behavior, WSL interactions, path handling, shell compatibility, and installation or runtime problems that only happen on Windows.
|
||||
**Only** add if the issue explicitly mentions nix.
|
||||
|
||||
If the issue does not mention nix, do not add nix.
|
||||
|
||||
If the issue mentions nix, assign to `rekram1-node`.
|
||||
|
||||
#### zen
|
||||
|
||||
**Only** add if the issue mentions "zen" or "opencode zen" or "opencode black".
|
||||
|
||||
If the issue doesn't have "zen" or "opencode black" in it then don't add zen label
|
||||
|
||||
#### core
|
||||
|
||||
Use for core server issues in `packages/opencode/`, excluding `packages/opencode/src/cli/cmd/tui/`.
|
||||
|
||||
Examples:
|
||||
|
||||
- LSP server behavior
|
||||
- Harness behavior (agent + tools)
|
||||
- Feature requests for server behavior
|
||||
- Agent context construction
|
||||
- API endpoints
|
||||
- Provider integration issues
|
||||
- New, broken, or poor-quality models
|
||||
|
||||
#### acp
|
||||
|
||||
If the issue mentions acp support, assign acp label.
|
||||
|
||||
#### docs
|
||||
|
||||
Add if the issue requests better documentation or docs updates.
|
||||
|
||||
#### opentui
|
||||
|
||||
TUI issues potentially caused by our underlying TUI library:
|
||||
|
||||
- Keybindings not working
|
||||
- Scroll speed issues (too fast/slow/laggy)
|
||||
- Screen flickering
|
||||
- Crashes with opentui in the log
|
||||
|
||||
**Do not** add for general TUI bugs.
|
||||
|
||||
When assigning to people here are the following rules:
|
||||
|
||||
Desktop / Web:
|
||||
Use for desktop-labeled issues only.
|
||||
|
||||
- adamdotdevin
|
||||
- iamdavidhill
|
||||
- Brendonovich
|
||||
- nexxeln
|
||||
|
||||
Zen:
|
||||
ONLY assign if the issue will have the "zen" label.
|
||||
|
||||
- fwang
|
||||
- MrMushrooooom
|
||||
|
||||
TUI (`packages/opencode/src/cli/cmd/tui/...`):
|
||||
|
||||
- thdxr for TUI UX/UI product decisions and interaction flow
|
||||
- kommander for OpenTUI engine issues: rendering artifacts, keybind handling, terminal compatibility, SSH behavior, and low-level perf bottlenecks
|
||||
- rekram1-node for TUI bugs that are not clearly OpenTUI engine issues
|
||||
|
||||
Core (`packages/opencode/...`, excluding TUI subtree):
|
||||
|
||||
- thdxr for sqlite/snapshot/memory bugs and larger architectural core features
|
||||
- jlongster for opencode server + API feature work (tool currently remaps jlongster -> thdxr until assignable)
|
||||
- rekram1-node for harness issues, provider issues, and other bug-squashing
|
||||
|
||||
For core bugs that do not clearly map, either thdxr or rekram1-node is acceptable.
|
||||
|
||||
Docs:
|
||||
|
||||
- R44VC0RP
|
||||
|
||||
Windows:
|
||||
|
||||
- Hona (assign any issue that mentions Windows or is likely Windows-specific)
|
||||
|
||||
Determinism rules:
|
||||
|
||||
- If title + body does not contain "zen", do not add the "zen" label
|
||||
- If "nix" label is added but title + body does not mention nix/nixos, the tool will drop "nix"
|
||||
- If title + body mentions nix/nixos, assign to `rekram1-node`
|
||||
- If "desktop" label is added, the tool will override assignee and randomly pick one Desktop / Web owner
|
||||
|
||||
In all other cases, choose the team/section with the most overlap with the issue and assign a member from that team at random.
|
||||
|
||||
ACP:
|
||||
|
||||
- rekram1-node (assign any acp issues to rekram1-node)
|
||||
|
||||
@@ -18,12 +18,9 @@ Do not use `git log` or author metadata when deciding attribution.
|
||||
|
||||
Rules:
|
||||
|
||||
- Write the final file with release sections in this order:
|
||||
- Write the final file with sections in this order:
|
||||
`## Core`, `## TUI`, `## Desktop`, `## SDK`, `## Extensions`
|
||||
- Only include sections that have at least one notable entry
|
||||
- Within each release section, keep bug fixes grouped under `### Bugfixes`
|
||||
- Keep other notable entries under `### Improvements` when a section has bug fixes too
|
||||
- Omit empty subsections
|
||||
- Keep one bullet per commit you keep
|
||||
- Skip commits that are entirely internal, CI, tests, refactors, or otherwise not user-facing
|
||||
- Start each bullet with a capital letter
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
/// <reference path="../env.d.ts" />
|
||||
import { tool } from "@opencode-ai/plugin"
|
||||
|
||||
const TEAM = {
|
||||
tui: ["kommander", "simonklee"],
|
||||
desktop_web: ["Hona", "Brendonovich"],
|
||||
core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton"],
|
||||
inference: ["fwang", "MrMushrooooom"],
|
||||
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
|
||||
zen: ["fwang", "MrMushrooooom"],
|
||||
tui: ["kommander", "rekram1-node", "simonklee"],
|
||||
core: ["kitlangton", "rekram1-node", "jlongster"],
|
||||
docs: ["R44VC0RP"],
|
||||
windows: ["Hona"],
|
||||
} as const
|
||||
|
||||
const ASSIGNEES = [...new Set(Object.values(TEAM).flat())]
|
||||
|
||||
function pick<T>(items: readonly T[]) {
|
||||
return items[Math.floor(Math.random() * items.length)]!
|
||||
}
|
||||
@@ -36,25 +38,79 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
|
||||
}
|
||||
|
||||
export default tool({
|
||||
description: `Use this tool to assign a GitHub issue.
|
||||
description: `Use this tool to assign and/or label a GitHub issue.
|
||||
|
||||
Provide the team that should own the issue. This tool picks a random assignee from that team and does not apply labels.`,
|
||||
Choose labels and assignee using the current triage policy and ownership rules.
|
||||
Pick the most fitting labels for the issue and assign one owner.
|
||||
|
||||
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.`,
|
||||
args: {
|
||||
team: tool.schema
|
||||
.enum(Object.keys(TEAM) as [keyof typeof TEAM, ...(keyof typeof TEAM)[]])
|
||||
.describe("The owning team"),
|
||||
assignee: tool.schema
|
||||
.enum(ASSIGNEES as [string, ...string[]])
|
||||
.describe("The username of the assignee")
|
||||
.default("rekram1-node"),
|
||||
labels: tool.schema
|
||||
.array(tool.schema.enum(["nix", "opentui", "perf", "web", "desktop", "zen", "docs", "windows", "core"]))
|
||||
.describe("The labels(s) to add to the issue")
|
||||
.default([]),
|
||||
},
|
||||
async execute(args) {
|
||||
const issue = getIssueNumber()
|
||||
const owner = "anomalyco"
|
||||
const repo = "opencode"
|
||||
const assignee = pick(TEAM[args.team])
|
||||
|
||||
const results: string[] = []
|
||||
let labels = [...new Set(args.labels.map((x) => (x === "desktop" ? "web" : x)))]
|
||||
const web = labels.includes("web")
|
||||
const text = `${process.env.ISSUE_TITLE ?? ""}\n${process.env.ISSUE_BODY ?? ""}`.toLowerCase()
|
||||
const zen = /\bzen\b/.test(text) || text.includes("opencode black")
|
||||
const nix = /\bnix(os)?\b/.test(text)
|
||||
|
||||
if (labels.includes("nix") && !nix) {
|
||||
labels = labels.filter((x) => x !== "nix")
|
||||
results.push("Dropped label: nix (issue does not mention nix)")
|
||||
}
|
||||
|
||||
const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee
|
||||
|
||||
if (labels.includes("zen") && !zen) {
|
||||
throw new Error("Only add the zen label when issue title/body contains 'zen'")
|
||||
}
|
||||
|
||||
if (web && !nix && !(TEAM.desktop as readonly string[]).includes(assignee)) {
|
||||
throw new Error("Web issues must be assigned to adamdotdevin, iamdavidhill, Brendonovich, or nexxeln")
|
||||
}
|
||||
|
||||
if ((TEAM.zen as readonly string[]).includes(assignee) && !labels.includes("zen")) {
|
||||
throw new Error("Only zen issues should be assigned to fwang or MrMushrooooom")
|
||||
}
|
||||
|
||||
if (assignee === "Hona" && !labels.includes("windows")) {
|
||||
throw new Error("Only windows issues should be assigned to Hona")
|
||||
}
|
||||
|
||||
if (assignee === "R44VC0RP" && !labels.includes("docs")) {
|
||||
throw new Error("Only docs issues should be assigned to R44VC0RP")
|
||||
}
|
||||
|
||||
if (assignee === "kommander" && !labels.includes("opentui")) {
|
||||
throw new Error("Only opentui issues should be assigned to kommander")
|
||||
}
|
||||
|
||||
await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/assignees`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ assignees: [assignee] }),
|
||||
})
|
||||
results.push(`Assigned @${assignee} to issue #${issue}`)
|
||||
|
||||
return `Assigned @${assignee} from ${args.team} to issue #${issue}`
|
||||
if (labels.length > 0) {
|
||||
await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/labels`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ labels }),
|
||||
})
|
||||
results.push(`Added labels: ${labels.join(", ")}`)
|
||||
}
|
||||
|
||||
return results.join("\n")
|
||||
},
|
||||
})
|
||||
|
||||
@@ -132,7 +132,7 @@ It's very similar to Claude Code in terms of capability. Here are the key differ
|
||||
|
||||
- 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
|
||||
- Out-of-the-box 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.
|
||||
|
||||
|
||||
4
bun.lock
4
bun.lock
@@ -715,7 +715,7 @@
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
||||
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
||||
"effect": "4.0.0-beta.59",
|
||||
"effect": "4.0.0-beta.57",
|
||||
"fuzzysort": "3.1.0",
|
||||
"hono": "4.10.7",
|
||||
"hono-openapi": "1.1.2",
|
||||
@@ -3078,7 +3078,7 @@
|
||||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"effect": ["effect@4.0.0-beta.59", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-xyUDLeHSe8d6lWGOvR6Fgn2HL6gYeTZ/S4Jzk9uc4ZUxMPPsNZlNXrvk0C7/utQFzeX7uAWcVnG2BjbA0SRoAA=="],
|
||||
"effect": ["effect@4.0.0-beta.57", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-rg32VgXnLKaPRs9tbRDaZ5jxmzNY7ojXt85gSHGUTwdlbWH5Ik+OCUY2q14TXliygPGoHwCAvNWS4bQJOqf00g=="],
|
||||
|
||||
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-9wTDLZsuGjkWyVOb6AG2VRYPiaSj/lnXwVkSwNeDcns=",
|
||||
"aarch64-linux": "sha256-gmKlL2fQxY8bo+//8m9e1TNYJK3RXa4i8xsgtd046bc=",
|
||||
"aarch64-darwin": "sha256-ENSJK+7rZi3m342mjtGg9N0P6zWEypXMpI7QdFMydbc=",
|
||||
"x86_64-darwin": "sha256-gkxCxGh5dlwj03vZdz20pbiAwFEDpAlu/5iU8cwZOGI="
|
||||
"x86_64-linux": "sha256-SLWRe4uPSRWgU+NPa1BywmrUtNVIC0Oy2mjmxclxk+s=",
|
||||
"aarch64-linux": "sha256-toHEeIqMzrmThoV0B52juGKm4pa/aJN3gBFFtrSZp2Q=",
|
||||
"aarch64-darwin": "sha256-lYUsUxq5zR2RXjqZTEdjduOncnlwvTlxDJVKWXJuKPY=",
|
||||
"x86_64-darwin": "sha256-77XmuEYqGwb1mkEHfnghq1VtukFTneohA0FW6WDOk1U="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
||||
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
||||
"effect": "4.0.0-beta.59",
|
||||
"effect": "4.0.0-beta.57",
|
||||
"ai": "6.0.168",
|
||||
"cross-spawn": "7.0.6",
|
||||
"hono": "4.10.7",
|
||||
|
||||
@@ -24,7 +24,6 @@ export namespace AppFileSystem {
|
||||
readonly isDir: (path: string) => Effect.Effect<boolean>
|
||||
readonly isFile: (path: string) => Effect.Effect<boolean>
|
||||
readonly existsSafe: (path: string) => Effect.Effect<boolean>
|
||||
readonly readFileStringSafe: (path: string) => Effect.Effect<string | undefined, Error>
|
||||
readonly readJson: (path: string) => Effect.Effect<unknown, Error>
|
||||
readonly writeJson: (path: string, data: unknown, mode?: number) => Effect.Effect<void, Error>
|
||||
readonly ensureDir: (path: string) => Effect.Effect<void, Error>
|
||||
@@ -48,12 +47,6 @@ export namespace AppFileSystem {
|
||||
return yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false))
|
||||
})
|
||||
|
||||
const readFileStringSafe = Effect.fn("FileSystem.readFileStringSafe")(function* (path: string) {
|
||||
return yield* fs
|
||||
.readFileString(path)
|
||||
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
|
||||
})
|
||||
|
||||
const isDir = Effect.fn("FileSystem.isDir")(function* (path: string) {
|
||||
const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
|
||||
return info?.type === "Directory"
|
||||
@@ -170,7 +163,6 @@ export namespace AppFileSystem {
|
||||
return Service.of({
|
||||
...fs,
|
||||
existsSafe,
|
||||
readFileStringSafe,
|
||||
isDir,
|
||||
isFile,
|
||||
readDirectoryEntries,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Config } from "effect"
|
||||
import { InstallationChannel } from "../installation/version"
|
||||
|
||||
function truthy(key: string) {
|
||||
const value = process.env[key]?.toLowerCase()
|
||||
@@ -11,10 +10,6 @@ function falsy(key: string) {
|
||||
return value === "false" || value === "0"
|
||||
}
|
||||
|
||||
// Channels that default to the new effect-httpapi server backend. The legacy
|
||||
// hono backend remains the default for stable (`prod`/`latest`) installs.
|
||||
const HTTPAPI_DEFAULT_ON_CHANNELS = new Set(["dev", "beta", "local"])
|
||||
|
||||
function number(key: string) {
|
||||
const value = process.env[key]
|
||||
if (!value) return undefined
|
||||
@@ -86,16 +81,8 @@ export const Flag = {
|
||||
OPENCODE_STRICT_CONFIG_DEPS: truthy("OPENCODE_STRICT_CONFIG_DEPS"),
|
||||
|
||||
OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"],
|
||||
// Defaults to true on dev/beta/local channels so internal users exercise the
|
||||
// new effect-httpapi server backend. Stable (`prod`/`latest`) installs stay
|
||||
// on the legacy hono backend until the rollout is complete. An explicit env
|
||||
// var ("true"/"1" or "false"/"0") always wins, providing an opt-in for
|
||||
// stable users and an escape hatch for dev/beta users.
|
||||
OPENCODE_EXPERIMENTAL_HTTPAPI:
|
||||
truthy("OPENCODE_EXPERIMENTAL_HTTPAPI") ||
|
||||
(!falsy("OPENCODE_EXPERIMENTAL_HTTPAPI") && HTTPAPI_DEFAULT_ON_CHANNELS.has(InstallationChannel)),
|
||||
OPENCODE_EXPERIMENTAL_HTTPAPI: truthy("OPENCODE_EXPERIMENTAL_HTTPAPI"),
|
||||
OPENCODE_EXPERIMENTAL_WORKSPACES: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES"),
|
||||
OPENCODE_EXPERIMENTAL_EVENT_SYSTEM: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"),
|
||||
|
||||
// Evaluated at access time (not module load) because tests, the CLI, and
|
||||
// external tooling set these env vars at runtime.
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
export * as Log from "./log"
|
||||
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { createWriteStream } from "fs"
|
||||
|
||||
@@ -65,34 +65,6 @@ describe("AppFileSystem", () => {
|
||||
)
|
||||
})
|
||||
|
||||
describe("readFileStringSafe", () => {
|
||||
it(
|
||||
"returns file contents when file exists",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const filesys = yield* FileSystem.FileSystem
|
||||
const tmp = yield* filesys.makeTempDirectoryScoped()
|
||||
const file = path.join(tmp, "exists.txt")
|
||||
yield* filesys.writeFileString(file, "hello")
|
||||
|
||||
const result = yield* fs.readFileStringSafe(file)
|
||||
expect(result).toBe("hello")
|
||||
}),
|
||||
)
|
||||
|
||||
it(
|
||||
"returns undefined for missing file (NotFound)",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const filesys = yield* FileSystem.FileSystem
|
||||
const tmp = yield* filesys.makeTempDirectoryScoped()
|
||||
|
||||
const result = yield* fs.readFileStringSafe(path.join(tmp, "does-not-exist.txt"))
|
||||
expect(result).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("readJson / writeJson", () => {
|
||||
it(
|
||||
"round-trips JSON data",
|
||||
|
||||
@@ -27,7 +27,7 @@ const channel = (() => {
|
||||
})()
|
||||
|
||||
const getBase = (): Configuration => ({
|
||||
artifactName: "opencode-desktop-${os}-${arch}.${ext}",
|
||||
artifactName: "opencode-electron-${os}-${arch}.${ext}",
|
||||
directories: {
|
||||
output: "dist",
|
||||
buildResources: "resources",
|
||||
|
||||
@@ -26,20 +26,13 @@ const applyZoom = (next: number) => {
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (!(OS_NAME === "macos" ? event.metaKey : event.ctrlKey)) return
|
||||
|
||||
if (event.key === "-") {
|
||||
event.preventDefault()
|
||||
applyZoom(clamp(webviewZoom() - 0.2))
|
||||
return
|
||||
}
|
||||
if (event.key === "=" || event.key === "+") {
|
||||
event.preventDefault()
|
||||
applyZoom(clamp(webviewZoom() + 0.2))
|
||||
return
|
||||
}
|
||||
if (event.key === "0") {
|
||||
event.preventDefault()
|
||||
applyZoom(1)
|
||||
}
|
||||
let newZoom = webviewZoom()
|
||||
|
||||
if (event.key === "-") newZoom -= 0.2
|
||||
if (event.key === "=" || event.key === "+") newZoom += 0.2
|
||||
if (event.key === "0") newZoom = 1
|
||||
|
||||
applyZoom(clamp(newZoom))
|
||||
})
|
||||
|
||||
export { webviewZoom }
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { Buffer } from "node:buffer"
|
||||
import { $ } from "bun"
|
||||
import path from "node:path"
|
||||
import { parseArgs } from "node:util"
|
||||
|
||||
const { values } = parseArgs({
|
||||
args: Bun.argv.slice(2),
|
||||
@@ -13,6 +12,8 @@ const { values } = parseArgs({
|
||||
|
||||
const dryRun = values["dry-run"]
|
||||
|
||||
import { parseArgs } from "node:util"
|
||||
|
||||
const repo = process.env.GH_REPO
|
||||
if (!repo) throw new Error("GH_REPO is required")
|
||||
|
||||
@@ -22,22 +23,20 @@ if (!releaseId) throw new Error("OPENCODE_RELEASE is required")
|
||||
const version = process.env.OPENCODE_VERSION
|
||||
if (!version) throw new Error("OPENCODE_VERSION is required")
|
||||
|
||||
const dir = process.env.LATEST_YML_DIR
|
||||
if (!dir) throw new Error("LATEST_YML_DIR is required")
|
||||
const root = dir
|
||||
|
||||
const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN
|
||||
if (!token) throw new Error("GH_TOKEN or GITHUB_TOKEN is required")
|
||||
|
||||
const rel = await fetch(`https://api.github.com/repos/${repo}/releases/${releaseId}`, {
|
||||
headers: {
|
||||
Authorization: `token ${token}`,
|
||||
Accept: "application/vnd.github+json",
|
||||
},
|
||||
const apiHeaders = {
|
||||
Authorization: `token ${token}`,
|
||||
Accept: "application/vnd.github+json",
|
||||
}
|
||||
|
||||
const releaseRes = await fetch(`https://api.github.com/repos/${repo}/releases/${releaseId}`, {
|
||||
headers: apiHeaders,
|
||||
})
|
||||
|
||||
if (!rel.ok) {
|
||||
throw new Error(`Failed to fetch release: ${rel.status} ${rel.statusText}`)
|
||||
if (!releaseRes.ok) {
|
||||
throw new Error(`Failed to fetch release: ${releaseRes.status} ${releaseRes.statusText}`)
|
||||
}
|
||||
|
||||
type Asset = {
|
||||
@@ -46,169 +45,115 @@ type Asset = {
|
||||
}
|
||||
|
||||
type Release = {
|
||||
tag_name?: string
|
||||
assets?: Asset[]
|
||||
}
|
||||
|
||||
const assets = ((await rel.json()) as Release).assets ?? []
|
||||
const amap = new Map(assets.map((item) => [item.name, item]))
|
||||
const release = (await releaseRes.json()) as Release
|
||||
const assets = release.assets ?? []
|
||||
const assetByName = new Map(assets.map((asset) => [asset.name, asset]))
|
||||
|
||||
type Item = {
|
||||
url: string
|
||||
const latestAsset = assetByName.get("latest.json")
|
||||
if (!latestAsset) {
|
||||
console.log("latest.json not found, skipping tauri finalization")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
type Yml = {
|
||||
version: string
|
||||
files: Item[]
|
||||
const latestRes = await fetch(latestAsset.url, {
|
||||
headers: {
|
||||
Authorization: `token ${token}`,
|
||||
Accept: "application/octet-stream",
|
||||
},
|
||||
})
|
||||
|
||||
if (!latestRes.ok) {
|
||||
throw new Error(`Failed to fetch latest.json: ${latestRes.status} ${latestRes.statusText}`)
|
||||
}
|
||||
|
||||
function parse(text: string): Yml {
|
||||
const lines = text.split("\n")
|
||||
let version = ""
|
||||
const files: Item[] = []
|
||||
let url = ""
|
||||
const latestText = new TextDecoder().decode(await latestRes.arrayBuffer())
|
||||
const latest = JSON.parse(latestText)
|
||||
const base = { ...latest }
|
||||
delete base.platforms
|
||||
|
||||
const flush = () => {
|
||||
if (!url) return
|
||||
files.push({ url })
|
||||
url = ""
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
const trim = line.trim()
|
||||
if (line.startsWith("version:")) {
|
||||
version = line.slice("version:".length).trim()
|
||||
continue
|
||||
}
|
||||
if (trim.startsWith("- url:")) {
|
||||
flush()
|
||||
url = trim.slice("- url:".length).trim()
|
||||
continue
|
||||
}
|
||||
const indented = line.startsWith(" ") || line.startsWith("\t")
|
||||
if (!indented) flush()
|
||||
}
|
||||
flush()
|
||||
|
||||
return { version, files }
|
||||
}
|
||||
|
||||
async function read(sub: string, file: string) {
|
||||
const item = Bun.file(path.join(root, sub, file))
|
||||
if (!(await item.exists())) return undefined
|
||||
return parse(await item.text())
|
||||
}
|
||||
|
||||
function pick(list: Item[], exts: string[]) {
|
||||
for (const ext of exts) {
|
||||
const found = list.find((item) => item.url.split("?")[0]?.toLowerCase().endsWith(ext))
|
||||
if (found) return found.url
|
||||
}
|
||||
}
|
||||
|
||||
function link(raw: string) {
|
||||
if (raw.startsWith("https://") || raw.startsWith("http://")) return raw
|
||||
return `https://github.com/${repo}/releases/download/v${version}/${raw}`
|
||||
}
|
||||
|
||||
async function sign(url: string, key: string) {
|
||||
const name = decodeURIComponent(new URL(url).pathname.split("/").pop() ?? key)
|
||||
const asset = amap.get(name)
|
||||
const res = await fetch(asset?.url ?? url, {
|
||||
const fetchSignature = async (asset: Asset) => {
|
||||
const res = await fetch(asset.url, {
|
||||
headers: {
|
||||
Authorization: `token ${token}`,
|
||||
...(asset ? { Accept: "application/octet-stream" } : {}),
|
||||
Accept: "application/octet-stream",
|
||||
},
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch file ${name}: ${res.status} ${res.statusText} (${asset?.url ?? url})`)
|
||||
throw new Error(`Failed to fetch signature: ${res.status} ${res.statusText}`)
|
||||
}
|
||||
|
||||
const tmp = process.env.RUNNER_TEMP ?? "/tmp"
|
||||
const file = path.join(tmp, name)
|
||||
await Bun.write(file, await res.arrayBuffer())
|
||||
await $`bunx @tauri-apps/cli signer sign ${file}`
|
||||
const sigFile = Bun.file(`${file}.sig`)
|
||||
if (!(await sigFile.exists())) throw new Error(`Signature file not found for ${name}`)
|
||||
return (await sigFile.text()).trim()
|
||||
return Buffer.from(await res.arrayBuffer()).toString()
|
||||
}
|
||||
|
||||
const add = async (data: Record<string, { url: string; signature: string }>, key: string, raw: string | undefined) => {
|
||||
if (!raw) return
|
||||
if (data[key]) return
|
||||
const url = link(raw)
|
||||
data[key] = { url, signature: await sign(url, key) }
|
||||
const entries: Record<string, { url: string; signature: string }> = {}
|
||||
const add = (key: string, asset: Asset, signature: string) => {
|
||||
if (entries[key]) return
|
||||
entries[key] = {
|
||||
url: `https://github.com/${repo}/releases/download/v${version}/${asset.name}`,
|
||||
signature,
|
||||
}
|
||||
}
|
||||
|
||||
const alias = (data: Record<string, { url: string; signature: string }>, key: string, src: string) => {
|
||||
if (data[key]) return
|
||||
if (!data[src]) return
|
||||
data[key] = data[src]
|
||||
const targets = [
|
||||
{ key: "linux-x86_64-deb", asset: "opencode-desktop-linux-amd64.deb" },
|
||||
{ key: "linux-x86_64-rpm", asset: "opencode-desktop-linux-x86_64.rpm" },
|
||||
{ key: "linux-aarch64-deb", asset: "opencode-desktop-linux-arm64.deb" },
|
||||
{ key: "linux-aarch64-rpm", asset: "opencode-desktop-linux-aarch64.rpm" },
|
||||
{ key: "windows-aarch64-nsis", asset: "opencode-desktop-windows-arm64.exe" },
|
||||
{ key: "windows-x86_64-nsis", asset: "opencode-desktop-windows-x64.exe" },
|
||||
{ key: "darwin-x86_64-app", asset: "opencode-desktop-darwin-x64.app.tar.gz" },
|
||||
{
|
||||
key: "darwin-aarch64-app",
|
||||
asset: "opencode-desktop-darwin-aarch64.app.tar.gz",
|
||||
},
|
||||
]
|
||||
|
||||
for (const target of targets) {
|
||||
const asset = assetByName.get(target.asset)
|
||||
if (!asset) continue
|
||||
|
||||
const sig = assetByName.get(`${target.asset}.sig`)
|
||||
if (!sig) continue
|
||||
|
||||
const signature = await fetchSignature(sig)
|
||||
add(target.key, asset, signature)
|
||||
}
|
||||
|
||||
const winx = await read("latest-yml-x86_64-pc-windows-msvc", "latest.yml")
|
||||
const wina = await read("latest-yml-aarch64-pc-windows-msvc", "latest.yml")
|
||||
const macx = await read("latest-yml-x86_64-apple-darwin", "latest-mac.yml")
|
||||
const maca = await read("latest-yml-aarch64-apple-darwin", "latest-mac.yml")
|
||||
const linx = await read("latest-yml-x86_64-unknown-linux-gnu", "latest-linux.yml")
|
||||
const lina = await read("latest-yml-aarch64-unknown-linux-gnu", "latest-linux-arm64.yml")
|
||||
const alias = (key: string, source: string) => {
|
||||
if (entries[key]) return
|
||||
const entry = entries[source]
|
||||
if (!entry) return
|
||||
entries[key] = entry
|
||||
}
|
||||
|
||||
const yver = winx?.version ?? wina?.version ?? macx?.version ?? maca?.version ?? linx?.version ?? lina?.version
|
||||
if (yver && yver !== version) throw new Error(`latest.yml version mismatch: expected ${version}, got ${yver}`)
|
||||
|
||||
const out: Record<string, { url: string; signature: string }> = {}
|
||||
|
||||
const winxexe = pick(winx?.files ?? [], [".exe"])
|
||||
const winaexe = pick(wina?.files ?? [], [".exe"])
|
||||
|
||||
const macxTarGz = "opencode-desktop-mac-x64.app.tar.gz"
|
||||
const macaTarGz = "opencode-desktop-mac-arm64.app.tar.gz"
|
||||
|
||||
const linxDeb = pick(linx?.files ?? [], [".deb"])
|
||||
const linxRpm = pick(linx?.files ?? [], [".rpm"])
|
||||
const linxAppImage = pick(linx?.files ?? [], [".appimage"])
|
||||
const linaDeb = pick(lina?.files ?? [], [".deb"])
|
||||
const linaRpm = pick(lina?.files ?? [], [".rpm"])
|
||||
const linaAppImage = pick(lina?.files ?? [], [".appimage"])
|
||||
|
||||
await add(out, "windows-x86_64-nsis", winxexe)
|
||||
await add(out, "windows-aarch64-nsis", winaexe)
|
||||
await add(out, "darwin-x86_64-app", macxTarGz)
|
||||
await add(out, "darwin-aarch64-app", macaTarGz)
|
||||
|
||||
await add(out, "linux-x86_64-deb", linxDeb)
|
||||
await add(out, "linux-x86_64-rpm", linxRpm)
|
||||
await add(out, "linux-x86_64-appimage", linxAppImage)
|
||||
await add(out, "linux-aarch64-deb", linaDeb)
|
||||
await add(out, "linux-aarch64-rpm", linaRpm)
|
||||
await add(out, "linux-aarch64-appimage", linaAppImage)
|
||||
|
||||
alias(out, "windows-x86_64", "windows-x86_64-nsis")
|
||||
alias(out, "windows-aarch64", "windows-aarch64-nsis")
|
||||
alias(out, "darwin-x86_64", "darwin-x86_64-app")
|
||||
alias(out, "darwin-aarch64", "darwin-aarch64-app")
|
||||
alias(out, "linux-x86_64", "linux-x86_64-deb")
|
||||
alias(out, "linux-aarch64", "linux-aarch64-deb")
|
||||
alias("linux-x86_64", "linux-x86_64-deb")
|
||||
alias("linux-aarch64", "linux-aarch64-deb")
|
||||
alias("windows-aarch64", "windows-aarch64-nsis")
|
||||
alias("windows-x86_64", "windows-x86_64-nsis")
|
||||
alias("darwin-x86_64", "darwin-x86_64-app")
|
||||
alias("darwin-aarch64", "darwin-aarch64-app")
|
||||
|
||||
const platforms = Object.fromEntries(
|
||||
Object.keys(out)
|
||||
Object.keys(entries)
|
||||
.sort()
|
||||
.map((key) => [key, out[key]]),
|
||||
.map((key) => [key, entries[key]]),
|
||||
)
|
||||
|
||||
if (!Object.keys(platforms).length) throw new Error("No updater files found in latest.yml artifacts")
|
||||
|
||||
const data = {
|
||||
version,
|
||||
notes: "",
|
||||
pub_date: new Date().toISOString(),
|
||||
const output = {
|
||||
...base,
|
||||
platforms,
|
||||
}
|
||||
|
||||
const tmp = process.env.RUNNER_TEMP ?? "/tmp"
|
||||
const file = path.join(tmp, "latest.json")
|
||||
await Bun.write(file, JSON.stringify(data, null, 2))
|
||||
const dir = process.env.RUNNER_TEMP ?? "/tmp"
|
||||
const file = `${dir}/latest.json`
|
||||
await Bun.write(file, JSON.stringify(output, null, 2))
|
||||
|
||||
const tag = `v${version}`
|
||||
const tag = release.tag_name
|
||||
if (!tag) throw new Error("Release tag not found")
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`dry-run: wrote latest.json for ${tag} to ${file}`)
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
CREATE TABLE `session_message` (
|
||||
`id` text PRIMARY KEY,
|
||||
`session_id` text NOT NULL,
|
||||
`type` text NOT NULL,
|
||||
`time_created` integer NOT NULL,
|
||||
`time_updated` integer NOT NULL,
|
||||
`data` text NOT NULL,
|
||||
CONSTRAINT `fk_session_message_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DROP INDEX IF EXISTS `session_entry_session_idx`;--> statement-breakpoint
|
||||
DROP INDEX IF EXISTS `session_entry_session_type_idx`;--> statement-breakpoint
|
||||
DROP INDEX IF EXISTS `session_entry_time_created_idx`;--> statement-breakpoint
|
||||
CREATE INDEX `session_message_session_idx` ON `session_message` (`session_id`);--> statement-breakpoint
|
||||
CREATE INDEX `session_message_session_type_idx` ON `session_message` (`session_id`,`type`);--> statement-breakpoint
|
||||
CREATE INDEX `session_message_time_created_idx` ON `session_message` (`time_created`);--> statement-breakpoint
|
||||
DROP TABLE `session_entry`;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"id": "aaa2ebeb-caa4-478d-8365-4fc595d16856",
|
||||
"prevIds": ["61f807f9-6398-4067-be05-804acc2561bc"],
|
||||
"prevIds": ["66cbe0d7-def0-451b-b88a-7608513a9b44"],
|
||||
"ddl": [
|
||||
{
|
||||
"name": "account_state",
|
||||
@@ -37,7 +37,7 @@
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "session_message",
|
||||
"name": "session_entry",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
@@ -598,7 +598,7 @@
|
||||
"generated": null,
|
||||
"name": "id",
|
||||
"entityType": "columns",
|
||||
"table": "session_message"
|
||||
"table": "session_entry"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -608,7 +608,7 @@
|
||||
"generated": null,
|
||||
"name": "session_id",
|
||||
"entityType": "columns",
|
||||
"table": "session_message"
|
||||
"table": "session_entry"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -618,7 +618,7 @@
|
||||
"generated": null,
|
||||
"name": "type",
|
||||
"entityType": "columns",
|
||||
"table": "session_message"
|
||||
"table": "session_entry"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
@@ -628,7 +628,7 @@
|
||||
"generated": null,
|
||||
"name": "time_created",
|
||||
"entityType": "columns",
|
||||
"table": "session_message"
|
||||
"table": "session_entry"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
@@ -638,7 +638,7 @@
|
||||
"generated": null,
|
||||
"name": "time_updated",
|
||||
"entityType": "columns",
|
||||
"table": "session_message"
|
||||
"table": "session_entry"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -648,7 +648,7 @@
|
||||
"generated": null,
|
||||
"name": "data",
|
||||
"entityType": "columns",
|
||||
"table": "session_message"
|
||||
"table": "session_entry"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1112,9 +1112,9 @@
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
"name": "fk_session_message_session_id_session_id_fk",
|
||||
"name": "fk_session_entry_session_id_session_id_fk",
|
||||
"entityType": "fks",
|
||||
"table": "session_message"
|
||||
"table": "session_entry"
|
||||
},
|
||||
{
|
||||
"columns": ["project_id"],
|
||||
@@ -1226,8 +1226,8 @@
|
||||
{
|
||||
"columns": ["id"],
|
||||
"nameExplicit": false,
|
||||
"name": "session_message_pk",
|
||||
"table": "session_message",
|
||||
"name": "session_entry_pk",
|
||||
"table": "session_entry",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
@@ -1322,9 +1322,9 @@
|
||||
"isUnique": false,
|
||||
"where": null,
|
||||
"origin": "manual",
|
||||
"name": "session_message_session_idx",
|
||||
"name": "session_entry_session_idx",
|
||||
"entityType": "indexes",
|
||||
"table": "session_message"
|
||||
"table": "session_entry"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
@@ -1340,9 +1340,9 @@
|
||||
"isUnique": false,
|
||||
"where": null,
|
||||
"origin": "manual",
|
||||
"name": "session_message_session_type_idx",
|
||||
"name": "session_entry_session_type_idx",
|
||||
"entityType": "indexes",
|
||||
"table": "session_message"
|
||||
"table": "session_entry"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
@@ -1354,9 +1354,9 @@
|
||||
"isUnique": false,
|
||||
"where": null,
|
||||
"origin": "manual",
|
||||
"name": "session_message_time_created_idx",
|
||||
"name": "session_entry_time_created_idx",
|
||||
"entityType": "indexes",
|
||||
"table": "session_message"
|
||||
"table": "session_entry"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE `session` ADD `agent` text;--> statement-breakpoint
|
||||
ALTER TABLE `session` ADD `model` text;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -61,7 +61,6 @@ const createEmbeddedWebUIBundle = async () => {
|
||||
await $`bun run --cwd ${appDir} build`
|
||||
const files = (await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: dist })))
|
||||
.map((file) => file.replaceAll("\\", "/"))
|
||||
.filter((file) => !file.endsWith(".map"))
|
||||
.sort()
|
||||
const imports = files.map((file, i) => {
|
||||
const spec = path.relative(dir, path.join(dist, file)).replaceAll("\\", "/")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -51,7 +51,6 @@ import { LoadAPIKeyError } from "ai"
|
||||
import type { AssistantMessage, Event, OpencodeClient, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import { applyPatch } from "diff"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { ShellID } from "@/tool/shell/id"
|
||||
|
||||
type ModeOption = { id: string; name: string; description?: string }
|
||||
type ModelOption = { modelId: string; name: string }
|
||||
@@ -130,7 +129,7 @@ async function sendUsageUpdate(
|
||||
})
|
||||
}
|
||||
|
||||
export function init({ sdk: _sdk }: { sdk: OpencodeClient }) {
|
||||
export async function init({ sdk: _sdk }: { sdk: OpencodeClient }) {
|
||||
return {
|
||||
create: (connection: AgentSideConnection, fullConfig: ACPConfig) => {
|
||||
return new Agent(connection, fullConfig)
|
||||
@@ -145,7 +144,7 @@ export class Agent implements ACPAgent {
|
||||
private sessionManager: ACPSessionManager
|
||||
private eventAbort = new AbortController()
|
||||
private eventStarted = false
|
||||
private shellSnapshots = new Map<string, string>()
|
||||
private bashSnapshots = new Map<string, string>()
|
||||
private toolStarts = new Set<string>()
|
||||
private permissionQueues = new Map<string, Promise<void>>()
|
||||
private permissionOptions: PermissionOption[] = [
|
||||
@@ -284,16 +283,16 @@ export class Agent implements ACPAgent {
|
||||
|
||||
switch (part.state.status) {
|
||||
case "pending":
|
||||
this.shellSnapshots.delete(part.callID)
|
||||
this.bashSnapshots.delete(part.callID)
|
||||
return
|
||||
|
||||
case "running":
|
||||
const output = this.shellOutput(part)
|
||||
const output = this.bashOutput(part)
|
||||
const content: ToolCallContent[] = []
|
||||
if (output) {
|
||||
const hash = Hash.fast(output)
|
||||
if (part.tool === ShellID.ToolID) {
|
||||
if (this.shellSnapshots.get(part.callID) === hash) {
|
||||
if (part.tool === "bash") {
|
||||
if (this.bashSnapshots.get(part.callID) === hash) {
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
@@ -312,7 +311,7 @@ export class Agent implements ACPAgent {
|
||||
})
|
||||
return
|
||||
}
|
||||
this.shellSnapshots.set(part.callID, hash)
|
||||
this.bashSnapshots.set(part.callID, hash)
|
||||
}
|
||||
content.push({
|
||||
type: "content",
|
||||
@@ -343,7 +342,7 @@ export class Agent implements ACPAgent {
|
||||
|
||||
case "completed": {
|
||||
this.toolStarts.delete(part.callID)
|
||||
this.shellSnapshots.delete(part.callID)
|
||||
this.bashSnapshots.delete(part.callID)
|
||||
const kind = toToolKind(part.tool)
|
||||
const content: ToolCallContent[] = [
|
||||
{
|
||||
@@ -424,7 +423,7 @@ export class Agent implements ACPAgent {
|
||||
}
|
||||
case "error":
|
||||
this.toolStarts.delete(part.callID)
|
||||
this.shellSnapshots.delete(part.callID)
|
||||
this.bashSnapshots.delete(part.callID)
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
@@ -838,10 +837,10 @@ export class Agent implements ACPAgent {
|
||||
await this.toolStart(sessionId, part)
|
||||
switch (part.state.status) {
|
||||
case "pending":
|
||||
this.shellSnapshots.delete(part.callID)
|
||||
this.bashSnapshots.delete(part.callID)
|
||||
break
|
||||
case "running":
|
||||
const output = this.shellOutput(part)
|
||||
const output = this.bashOutput(part)
|
||||
const runningContent: ToolCallContent[] = []
|
||||
if (output) {
|
||||
runningContent.push({
|
||||
@@ -872,7 +871,7 @@ export class Agent implements ACPAgent {
|
||||
break
|
||||
case "completed":
|
||||
this.toolStarts.delete(part.callID)
|
||||
this.shellSnapshots.delete(part.callID)
|
||||
this.bashSnapshots.delete(part.callID)
|
||||
const kind = toToolKind(part.tool)
|
||||
const content: ToolCallContent[] = [
|
||||
{
|
||||
@@ -952,7 +951,7 @@ export class Agent implements ACPAgent {
|
||||
break
|
||||
case "error":
|
||||
this.toolStarts.delete(part.callID)
|
||||
this.shellSnapshots.delete(part.callID)
|
||||
this.bashSnapshots.delete(part.callID)
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
@@ -1106,8 +1105,8 @@ export class Agent implements ACPAgent {
|
||||
}
|
||||
}
|
||||
|
||||
private shellOutput(part: ToolPart) {
|
||||
if (part.tool !== ShellID.ToolID) return
|
||||
private bashOutput(part: ToolPart) {
|
||||
if (part.tool !== "bash") return
|
||||
if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return
|
||||
const output = part.state.metadata["output"]
|
||||
if (typeof output !== "string") return
|
||||
@@ -1550,11 +1549,9 @@ export class Agent implements ACPAgent {
|
||||
|
||||
function toToolKind(toolName: string): ToolKind {
|
||||
const tool = toolName.toLocaleLowerCase()
|
||||
|
||||
switch (tool) {
|
||||
case ShellID.ToolID:
|
||||
case "bash":
|
||||
return "execute"
|
||||
|
||||
case "webfetch":
|
||||
return "fetch"
|
||||
|
||||
@@ -1579,7 +1576,6 @@ function toToolKind(toolName: string): ToolKind {
|
||||
|
||||
function toLocations(toolName: string, input: Record<string, any>): { path: string }[] {
|
||||
const tool = toolName.toLocaleLowerCase()
|
||||
|
||||
switch (tool) {
|
||||
case "read":
|
||||
case "edit":
|
||||
@@ -1588,7 +1584,7 @@ function toLocations(toolName: string, input: Record<string, any>): { path: stri
|
||||
case "glob":
|
||||
case "grep":
|
||||
return input["path"] ? [{ path: input["path"] }] : []
|
||||
case ShellID.ToolID:
|
||||
case "bash":
|
||||
return []
|
||||
default:
|
||||
return []
|
||||
|
||||
@@ -97,7 +97,6 @@ export const layer = Layer.effect(
|
||||
question: "deny",
|
||||
plan_enter: "deny",
|
||||
plan_exit: "deny",
|
||||
edit: "ask",
|
||||
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
|
||||
read: {
|
||||
"*": "allow",
|
||||
|
||||
@@ -24,7 +24,6 @@ export function payloads() {
|
||||
.map(([type, def]) => {
|
||||
return z
|
||||
.object({
|
||||
id: z.string(),
|
||||
type: z.literal(type),
|
||||
properties: zodObject(def.properties),
|
||||
})
|
||||
@@ -40,7 +39,6 @@ export function effectPayloads() {
|
||||
.entries()
|
||||
.map(([type, def]) =>
|
||||
Schema.Struct({
|
||||
id: Schema.String,
|
||||
type: Schema.Literal(type),
|
||||
properties: def.properties,
|
||||
}).annotate({ identifier: `Event.${type}` }),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { EventEmitter } from "events"
|
||||
import { Identifier } from "@/id/id"
|
||||
|
||||
export type GlobalEvent = {
|
||||
directory?: string
|
||||
@@ -8,15 +7,6 @@ export type GlobalEvent = {
|
||||
payload: any
|
||||
}
|
||||
|
||||
class GlobalBusEmitter extends EventEmitter<{
|
||||
export const GlobalBus = new EventEmitter<{
|
||||
event: [GlobalEvent]
|
||||
}> {
|
||||
override emit(eventName: "event", event: GlobalEvent): boolean {
|
||||
if (event.payload && typeof event.payload === "object" && !("id" in event.payload)) {
|
||||
event.payload.id = event.payload.syncEvent?.id ?? Identifier.create("evt", "ascending")
|
||||
}
|
||||
return super.emit(eventName, event)
|
||||
}
|
||||
}
|
||||
|
||||
export const GlobalBus = new GlobalBusEmitter()
|
||||
}>()
|
||||
|
||||
@@ -5,7 +5,6 @@ import { BusEvent } from "./bus-event"
|
||||
import { GlobalBus } from "./global"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { Identifier } from "@/id/id"
|
||||
|
||||
const log = Log.create({ service: "bus" })
|
||||
|
||||
@@ -19,7 +18,6 @@ export const InstanceDisposed = BusEvent.define(
|
||||
)
|
||||
|
||||
type Payload<D extends BusEvent.Definition = BusEvent.Definition> = {
|
||||
id: string
|
||||
type: D["type"]
|
||||
properties: BusProperties<D>
|
||||
}
|
||||
@@ -30,11 +28,7 @@ type State = {
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly publish: <D extends BusEvent.Definition>(
|
||||
def: D,
|
||||
properties: BusProperties<D>,
|
||||
options?: { id?: string },
|
||||
) => Effect.Effect<void>
|
||||
readonly publish: <D extends BusEvent.Definition>(def: D, properties: BusProperties<D>) => Effect.Effect<void>
|
||||
readonly subscribe: <D extends BusEvent.Definition>(def: D) => Stream.Stream<Payload<D>>
|
||||
readonly subscribeAll: () => Stream.Stream<Payload>
|
||||
readonly subscribeCallback: <D extends BusEvent.Definition>(
|
||||
@@ -59,7 +53,6 @@ export const layer = Layer.effect(
|
||||
// Publish InstanceDisposed before shutting down so subscribers see it
|
||||
yield* PubSub.publish(wildcard, {
|
||||
type: InstanceDisposed.type,
|
||||
id: createID(),
|
||||
properties: { directory: ctx.directory },
|
||||
})
|
||||
yield* PubSub.shutdown(wildcard)
|
||||
@@ -84,10 +77,10 @@ export const layer = Layer.effect(
|
||||
})
|
||||
}
|
||||
|
||||
function publish<D extends BusEvent.Definition>(def: D, properties: BusProperties<D>, options?: { id?: string }) {
|
||||
function publish<D extends BusEvent.Definition>(def: D, properties: BusProperties<D>) {
|
||||
return Effect.gen(function* () {
|
||||
const s = yield* InstanceState.get(state)
|
||||
const payload: Payload = { id: options?.id ?? createID(), type: def.type, properties }
|
||||
const payload: Payload = { type: def.type, properties }
|
||||
log.info("publishing", { type: def.type })
|
||||
|
||||
const ps = s.typed.get(def.type)
|
||||
@@ -180,16 +173,8 @@ const { runPromise, runSync } = makeRuntime(Service, layer)
|
||||
|
||||
// runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe,
|
||||
// Scope.make, Effect.forkScoped) is entirely synchronous. If any step becomes async, this will throw.
|
||||
export function createID() {
|
||||
return Identifier.create("evt", "ascending")
|
||||
}
|
||||
|
||||
export async function publish<D extends BusEvent.Definition>(
|
||||
def: D,
|
||||
properties: BusProperties<D>,
|
||||
options?: { id?: string },
|
||||
) {
|
||||
return runPromise((svc) => svc.publish(def, properties, options))
|
||||
export async function publish<D extends BusEvent.Definition>(def: D, properties: BusProperties<D>) {
|
||||
return runPromise((svc) => svc.publish(def, properties))
|
||||
}
|
||||
|
||||
export function subscribe<D extends BusEvent.Definition>(def: D, callback: (event: Payload<D>) => unknown) {
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { Instance } from "../project/instance"
|
||||
import { InstanceRuntime } from "../project/instance-runtime"
|
||||
import { WithInstance } from "../project/with-instance"
|
||||
import { InstanceStore } from "../project/instance-store"
|
||||
import { getBootstrapRunEffect } from "../effect/app-runtime"
|
||||
|
||||
export async function bootstrap<T>(directory: string, cb: () => Promise<T>) {
|
||||
return WithInstance.provide({
|
||||
return Instance.provide({
|
||||
directory,
|
||||
init: await getBootstrapRunEffect(),
|
||||
fn: async () => {
|
||||
try {
|
||||
const result = await cb()
|
||||
return result
|
||||
} finally {
|
||||
await InstanceRuntime.disposeInstance(Instance.current)
|
||||
await InstanceStore.disposeInstance(Instance.current)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Duration, Effect, Match, Option } from "effect"
|
||||
import { UI } from "../ui"
|
||||
import { Account } from "@/account/account"
|
||||
import { AccountID, OrgID, PollExpired, type PollResult, type AccountError } from "@/account/schema"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import * as Prompt from "../effect/prompt"
|
||||
import open from "open"
|
||||
|
||||
@@ -172,65 +172,60 @@ const openEffect = Effect.fn("open")(function* () {
|
||||
yield* Prompt.outro("Opened " + url)
|
||||
})
|
||||
|
||||
export const LoginCommand = effectCmd({
|
||||
export const LoginCommand = cmd({
|
||||
command: "login <url>",
|
||||
describe: false,
|
||||
instance: false,
|
||||
builder: (yargs) =>
|
||||
yargs.positional("url", {
|
||||
describe: "server URL",
|
||||
type: "string",
|
||||
demandOption: true,
|
||||
}),
|
||||
handler: Effect.fn("Cli.account.login")(function* (args) {
|
||||
async handler(args) {
|
||||
UI.empty()
|
||||
yield* Effect.orDie(loginEffect(args.url))
|
||||
}),
|
||||
await AppRuntime.runPromise(loginEffect(args.url))
|
||||
},
|
||||
})
|
||||
|
||||
export const LogoutCommand = effectCmd({
|
||||
export const LogoutCommand = cmd({
|
||||
command: "logout [email]",
|
||||
describe: false,
|
||||
instance: false,
|
||||
builder: (yargs) =>
|
||||
yargs.positional("email", {
|
||||
describe: "account email to log out from",
|
||||
type: "string",
|
||||
}),
|
||||
handler: Effect.fn("Cli.account.logout")(function* (args) {
|
||||
async handler(args) {
|
||||
UI.empty()
|
||||
yield* Effect.orDie(logoutEffect(args.email))
|
||||
}),
|
||||
await AppRuntime.runPromise(logoutEffect(args.email))
|
||||
},
|
||||
})
|
||||
|
||||
export const SwitchCommand = effectCmd({
|
||||
export const SwitchCommand = cmd({
|
||||
command: "switch",
|
||||
describe: false,
|
||||
instance: false,
|
||||
handler: Effect.fn("Cli.account.switch")(function* () {
|
||||
async handler() {
|
||||
UI.empty()
|
||||
yield* Effect.orDie(switchEffect())
|
||||
}),
|
||||
await AppRuntime.runPromise(switchEffect())
|
||||
},
|
||||
})
|
||||
|
||||
export const OrgsCommand = effectCmd({
|
||||
export const OrgsCommand = cmd({
|
||||
command: "orgs",
|
||||
describe: false,
|
||||
instance: false,
|
||||
handler: Effect.fn("Cli.account.orgs")(function* () {
|
||||
async handler() {
|
||||
UI.empty()
|
||||
yield* Effect.orDie(orgsEffect())
|
||||
}),
|
||||
await AppRuntime.runPromise(orgsEffect())
|
||||
},
|
||||
})
|
||||
|
||||
export const OpenCommand = effectCmd({
|
||||
export const OpenCommand = cmd({
|
||||
command: "open",
|
||||
describe: false,
|
||||
instance: false,
|
||||
handler: Effect.fn("Cli.account.open")(function* () {
|
||||
async handler() {
|
||||
UI.empty()
|
||||
yield* Effect.orDie(openEffect())
|
||||
}),
|
||||
await AppRuntime.runPromise(openEffect())
|
||||
},
|
||||
})
|
||||
|
||||
export const ConsoleCommand = cmd({
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Effect } from "effect"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
import { bootstrap } from "../bootstrap"
|
||||
import { cmd } from "./cmd"
|
||||
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"
|
||||
import { ACP } from "@/acp/agent"
|
||||
import { Server } from "@/server/server"
|
||||
import { ServerAuth } from "@/server/auth"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { withNetworkOptions, resolveNetworkOptions } from "../network"
|
||||
|
||||
const log = Log.create({ service: "acp-command" })
|
||||
|
||||
export const AcpCommand = effectCmd({
|
||||
export const AcpCommand = cmd({
|
||||
command: "acp",
|
||||
describe: "start ACP (Agent Client Protocol) server",
|
||||
builder: (yargs) => {
|
||||
@@ -20,54 +19,52 @@ export const AcpCommand = effectCmd({
|
||||
default: process.cwd(),
|
||||
})
|
||||
},
|
||||
handler: Effect.fn("Cli.acp")(function* (args) {
|
||||
handler: async (args) => {
|
||||
process.env.OPENCODE_CLIENT = "acp"
|
||||
const opts = yield* Effect.promise(() => resolveNetworkOptions(args))
|
||||
const server = yield* Effect.promise(() => Server.listen(opts))
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const opts = await resolveNetworkOptions(args)
|
||||
const server = await Server.listen(opts)
|
||||
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: `http://${server.hostname}:${server.port}`,
|
||||
headers: ServerAuth.headers(),
|
||||
})
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: `http://${server.hostname}:${server.port}`,
|
||||
})
|
||||
|
||||
const input = new WritableStream<Uint8Array>({
|
||||
write(chunk) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
process.stdout.write(chunk, (err) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
const input = new WritableStream<Uint8Array>({
|
||||
write(chunk) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
process.stdout.write(chunk, (err) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
const output = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
process.stdin.on("data", (chunk: Buffer) => {
|
||||
controller.enqueue(new Uint8Array(chunk))
|
||||
})
|
||||
process.stdin.on("end", () => controller.close())
|
||||
process.stdin.on("error", (err) => controller.error(err))
|
||||
},
|
||||
})
|
||||
|
||||
const stream = ndJsonStream(input, output)
|
||||
const agent = await ACP.init({ sdk })
|
||||
|
||||
new AgentSideConnection((conn) => {
|
||||
return agent.create(conn, { sdk })
|
||||
}, stream)
|
||||
|
||||
log.info("setup connection")
|
||||
process.stdin.resume()
|
||||
await new Promise((resolve, reject) => {
|
||||
process.stdin.on("end", resolve)
|
||||
process.stdin.on("error", reject)
|
||||
})
|
||||
})
|
||||
const output = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
process.stdin.on("data", (chunk: Buffer) => {
|
||||
controller.enqueue(new Uint8Array(chunk))
|
||||
})
|
||||
process.stdin.on("end", () => controller.close())
|
||||
process.stdin.on("error", (err) => controller.error(err))
|
||||
},
|
||||
})
|
||||
|
||||
const stream = ndJsonStream(input, output)
|
||||
const agent = ACP.init({ sdk })
|
||||
|
||||
new AgentSideConnection((conn) => {
|
||||
return agent.create(conn, { sdk })
|
||||
}, stream)
|
||||
|
||||
log.info("setup connection")
|
||||
process.stdin.resume()
|
||||
yield* Effect.promise(
|
||||
() =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
process.stdin.on("end", () => resolve())
|
||||
process.stdin.on("error", reject)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { cmd } from "./cmd"
|
||||
import * as prompts from "@clack/prompts"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { UI } from "../ui"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Agent } from "../../agent/agent"
|
||||
@@ -8,11 +9,9 @@ import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import matter from "gray-matter"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { Instance } from "../../project/instance"
|
||||
import { EOL } from "os"
|
||||
import type { Argv } from "yargs"
|
||||
import { Effect } from "effect"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
|
||||
type AgentMode = "all" | "primary" | "subagent"
|
||||
|
||||
@@ -33,7 +32,7 @@ const AVAILABLE_PERMISSIONS = [
|
||||
"skill",
|
||||
]
|
||||
|
||||
const AgentCreateCommand = effectCmd({
|
||||
const AgentCreateCommand = cmd({
|
||||
command: "create",
|
||||
describe: "create a new agent",
|
||||
builder: (yargs: Argv) =>
|
||||
@@ -61,191 +60,200 @@ const AgentCreateCommand = effectCmd({
|
||||
alias: ["m"],
|
||||
describe: "model to use in the format of provider/model",
|
||||
}),
|
||||
handler: Effect.fn("Cli.agent.create")(function* (args) {
|
||||
const maybeCtx = yield* InstanceRef
|
||||
if (!maybeCtx) return yield* Effect.die("InstanceRef not provided")
|
||||
const ctx = maybeCtx
|
||||
const agentSvc = yield* Agent.Service
|
||||
yield* Effect.promise(async () => {
|
||||
const cliPath = args.path
|
||||
const cliDescription = args.description
|
||||
const cliMode = args.mode as AgentMode | undefined
|
||||
const perms = args.permissions
|
||||
async handler(args) {
|
||||
await Instance.provide({
|
||||
directory: process.cwd(),
|
||||
async fn() {
|
||||
const cliPath = args.path
|
||||
const cliDescription = args.description
|
||||
const cliMode = args.mode as AgentMode | undefined
|
||||
const perms = args.permissions
|
||||
|
||||
const isFullyNonInteractive = cliPath && cliDescription && cliMode && perms !== undefined
|
||||
const isFullyNonInteractive = cliPath && cliDescription && cliMode && perms !== undefined
|
||||
|
||||
if (!isFullyNonInteractive) {
|
||||
UI.empty()
|
||||
prompts.intro("Create agent")
|
||||
}
|
||||
if (!isFullyNonInteractive) {
|
||||
UI.empty()
|
||||
prompts.intro("Create agent")
|
||||
}
|
||||
|
||||
const project = ctx.project
|
||||
const project = Instance.project
|
||||
|
||||
// Determine scope/path
|
||||
let targetPath: string
|
||||
if (cliPath) {
|
||||
targetPath = path.join(cliPath, "agent")
|
||||
} else {
|
||||
let scope: "global" | "project" = "global"
|
||||
if (project.vcs === "git") {
|
||||
const scopeResult = await prompts.select({
|
||||
message: "Location",
|
||||
// Determine scope/path
|
||||
let targetPath: string
|
||||
if (cliPath) {
|
||||
targetPath = path.join(cliPath, "agent")
|
||||
} else {
|
||||
let scope: "global" | "project" = "global"
|
||||
if (project.vcs === "git") {
|
||||
const scopeResult = await prompts.select({
|
||||
message: "Location",
|
||||
options: [
|
||||
{
|
||||
label: "Current project",
|
||||
value: "project" as const,
|
||||
hint: Instance.worktree,
|
||||
},
|
||||
{
|
||||
label: "Global",
|
||||
value: "global" as const,
|
||||
hint: Global.Path.config,
|
||||
},
|
||||
],
|
||||
})
|
||||
if (prompts.isCancel(scopeResult)) throw new UI.CancelledError()
|
||||
scope = scopeResult
|
||||
}
|
||||
targetPath = path.join(
|
||||
scope === "global" ? Global.Path.config : path.join(Instance.worktree, ".opencode"),
|
||||
"agent",
|
||||
)
|
||||
}
|
||||
|
||||
// Get description
|
||||
let description: string
|
||||
if (cliDescription) {
|
||||
description = cliDescription
|
||||
} else {
|
||||
const query = await prompts.text({
|
||||
message: "Description",
|
||||
placeholder: "What should this agent do?",
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(query)) throw new UI.CancelledError()
|
||||
description = query
|
||||
}
|
||||
|
||||
// Generate agent
|
||||
const spinner = prompts.spinner()
|
||||
spinner.start("Generating agent configuration...")
|
||||
const model = args.model ? Provider.parseModel(args.model) : undefined
|
||||
const generated = await AppRuntime.runPromise(
|
||||
Agent.Service.use((svc) => svc.generate({ description, model })),
|
||||
).catch((error) => {
|
||||
spinner.stop(`LLM failed to generate agent: ${error.message}`, 1)
|
||||
if (isFullyNonInteractive) process.exit(1)
|
||||
throw new UI.CancelledError()
|
||||
})
|
||||
spinner.stop(`Agent ${generated.identifier} generated`)
|
||||
|
||||
// Select permissions to allow
|
||||
let selected: string[]
|
||||
if (perms !== undefined) {
|
||||
selected = perms ? perms.split(",").map((t) => t.trim()) : AVAILABLE_PERMISSIONS
|
||||
} else {
|
||||
const result = await prompts.multiselect({
|
||||
message: "Select permissions to allow (Space to toggle)",
|
||||
options: AVAILABLE_PERMISSIONS.map((permission) => ({
|
||||
label: permission,
|
||||
value: permission,
|
||||
})),
|
||||
initialValues: AVAILABLE_PERMISSIONS,
|
||||
})
|
||||
if (prompts.isCancel(result)) throw new UI.CancelledError()
|
||||
selected = result
|
||||
}
|
||||
|
||||
// Get mode
|
||||
let mode: AgentMode
|
||||
if (cliMode) {
|
||||
mode = cliMode
|
||||
} else {
|
||||
const modeResult = await prompts.select({
|
||||
message: "Agent mode",
|
||||
options: [
|
||||
{
|
||||
label: "Current project",
|
||||
value: "project" as const,
|
||||
hint: ctx.worktree,
|
||||
label: "All",
|
||||
value: "all" as const,
|
||||
hint: "Can function in both primary and subagent roles",
|
||||
},
|
||||
{
|
||||
label: "Global",
|
||||
value: "global" as const,
|
||||
hint: Global.Path.config,
|
||||
label: "Primary",
|
||||
value: "primary" as const,
|
||||
hint: "Acts as a primary/main agent",
|
||||
},
|
||||
{
|
||||
label: "Subagent",
|
||||
value: "subagent" as const,
|
||||
hint: "Can be used as a subagent by other agents",
|
||||
},
|
||||
],
|
||||
initialValue: "all" as const,
|
||||
})
|
||||
if (prompts.isCancel(scopeResult)) throw new UI.CancelledError()
|
||||
scope = scopeResult
|
||||
if (prompts.isCancel(modeResult)) throw new UI.CancelledError()
|
||||
mode = modeResult
|
||||
}
|
||||
targetPath = path.join(scope === "global" ? Global.Path.config : path.join(ctx.worktree, ".opencode"), "agent")
|
||||
}
|
||||
|
||||
// Get description
|
||||
let description: string
|
||||
if (cliDescription) {
|
||||
description = cliDescription
|
||||
} else {
|
||||
const query = await prompts.text({
|
||||
message: "Description",
|
||||
placeholder: "What should this agent do?",
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(query)) throw new UI.CancelledError()
|
||||
description = query
|
||||
}
|
||||
|
||||
// Generate agent
|
||||
const spinner = prompts.spinner()
|
||||
spinner.start("Generating agent configuration...")
|
||||
const model = args.model ? Provider.parseModel(args.model) : undefined
|
||||
const generated = await Effect.runPromise(agentSvc.generate({ description, model })).catch((error) => {
|
||||
spinner.stop(`LLM failed to generate agent: ${error.message}`, 1)
|
||||
if (isFullyNonInteractive) process.exit(1)
|
||||
throw new UI.CancelledError()
|
||||
})
|
||||
spinner.stop(`Agent ${generated.identifier} generated`)
|
||||
|
||||
// Select permissions to allow
|
||||
let selected: string[]
|
||||
if (perms !== undefined) {
|
||||
selected = perms ? perms.split(",").map((t) => t.trim()) : AVAILABLE_PERMISSIONS
|
||||
} else {
|
||||
const result = await prompts.multiselect({
|
||||
message: "Select permissions to allow (Space to toggle)",
|
||||
options: AVAILABLE_PERMISSIONS.map((permission) => ({
|
||||
label: permission,
|
||||
value: permission,
|
||||
})),
|
||||
initialValues: AVAILABLE_PERMISSIONS,
|
||||
})
|
||||
if (prompts.isCancel(result)) throw new UI.CancelledError()
|
||||
selected = result
|
||||
}
|
||||
|
||||
// Get mode
|
||||
let mode: AgentMode
|
||||
if (cliMode) {
|
||||
mode = cliMode
|
||||
} else {
|
||||
const modeResult = await prompts.select({
|
||||
message: "Agent mode",
|
||||
options: [
|
||||
{
|
||||
label: "All",
|
||||
value: "all" as const,
|
||||
hint: "Can function in both primary and subagent roles",
|
||||
},
|
||||
{
|
||||
label: "Primary",
|
||||
value: "primary" as const,
|
||||
hint: "Acts as a primary/main agent",
|
||||
},
|
||||
{
|
||||
label: "Subagent",
|
||||
value: "subagent" as const,
|
||||
hint: "Can be used as a subagent by other agents",
|
||||
},
|
||||
],
|
||||
initialValue: "all" as const,
|
||||
})
|
||||
if (prompts.isCancel(modeResult)) throw new UI.CancelledError()
|
||||
mode = modeResult
|
||||
}
|
||||
|
||||
// Build permissions config — deny anything not explicitly selected.
|
||||
const permissions: Record<string, "deny"> = {}
|
||||
for (const permission of AVAILABLE_PERMISSIONS) {
|
||||
if (!selected.includes(permission)) {
|
||||
permissions[permission] = "deny"
|
||||
// Build permissions config — deny anything not explicitly selected.
|
||||
const permissions: Record<string, "deny"> = {}
|
||||
for (const permission of AVAILABLE_PERMISSIONS) {
|
||||
if (!selected.includes(permission)) {
|
||||
permissions[permission] = "deny"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build frontmatter
|
||||
const frontmatter: {
|
||||
description: string
|
||||
mode: AgentMode
|
||||
permission?: Record<string, "deny">
|
||||
} = {
|
||||
description: generated.whenToUse,
|
||||
mode,
|
||||
}
|
||||
if (Object.keys(permissions).length > 0) {
|
||||
frontmatter.permission = permissions
|
||||
}
|
||||
// Build frontmatter
|
||||
const frontmatter: {
|
||||
description: string
|
||||
mode: AgentMode
|
||||
permission?: Record<string, "deny">
|
||||
} = {
|
||||
description: generated.whenToUse,
|
||||
mode,
|
||||
}
|
||||
if (Object.keys(permissions).length > 0) {
|
||||
frontmatter.permission = permissions
|
||||
}
|
||||
|
||||
// Write file
|
||||
const content = matter.stringify(generated.systemPrompt, frontmatter)
|
||||
const filePath = path.join(targetPath, `${generated.identifier}.md`)
|
||||
// Write file
|
||||
const content = matter.stringify(generated.systemPrompt, frontmatter)
|
||||
const filePath = path.join(targetPath, `${generated.identifier}.md`)
|
||||
|
||||
await fs.mkdir(targetPath, { recursive: true })
|
||||
await fs.mkdir(targetPath, { recursive: true })
|
||||
|
||||
if (await Filesystem.exists(filePath)) {
|
||||
if (isFullyNonInteractive) {
|
||||
console.error(`Error: Agent file already exists: ${filePath}`)
|
||||
process.exit(1)
|
||||
}
|
||||
prompts.log.error(`Agent file already exists: ${filePath}`)
|
||||
throw new UI.CancelledError()
|
||||
}
|
||||
|
||||
await Filesystem.write(filePath, content)
|
||||
|
||||
if (await Filesystem.exists(filePath)) {
|
||||
if (isFullyNonInteractive) {
|
||||
console.error(`Error: Agent file already exists: ${filePath}`)
|
||||
process.exit(1)
|
||||
console.log(filePath)
|
||||
} else {
|
||||
prompts.log.success(`Agent created: ${filePath}`)
|
||||
prompts.outro("Done")
|
||||
}
|
||||
prompts.log.error(`Agent file already exists: ${filePath}`)
|
||||
throw new UI.CancelledError()
|
||||
}
|
||||
|
||||
await Filesystem.write(filePath, content)
|
||||
|
||||
if (isFullyNonInteractive) {
|
||||
console.log(filePath)
|
||||
} else {
|
||||
prompts.log.success(`Agent created: ${filePath}`)
|
||||
prompts.outro("Done")
|
||||
}
|
||||
},
|
||||
})
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const AgentListCommand = effectCmd({
|
||||
const AgentListCommand = cmd({
|
||||
command: "list",
|
||||
describe: "list all available agents",
|
||||
handler: Effect.fn("Cli.agent.list")(function* () {
|
||||
const agents = yield* Agent.Service.use((svc) => svc.list())
|
||||
const sortedAgents = agents.sort((a, b) => {
|
||||
if (a.native !== b.native) {
|
||||
return a.native ? -1 : 1
|
||||
}
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
async handler() {
|
||||
await Instance.provide({
|
||||
directory: process.cwd(),
|
||||
async fn() {
|
||||
const agents = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.list()))
|
||||
const sortedAgents = agents.sort((a, b) => {
|
||||
if (a.native !== b.native) {
|
||||
return a.native ? -1 : 1
|
||||
}
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
|
||||
for (const agent of sortedAgents) {
|
||||
process.stdout.write(`${agent.name} (${agent.mode})` + EOL)
|
||||
process.stdout.write(` ${JSON.stringify(agent.permission, null, 2)}` + EOL)
|
||||
}
|
||||
}),
|
||||
for (const agent of sortedAgents) {
|
||||
process.stdout.write(`${agent.name} (${agent.mode})` + EOL)
|
||||
process.stdout.write(` ${JSON.stringify(agent.permission, null, 2)}` + EOL)
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const AgentCommand = cmd({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CommandModule } from "yargs"
|
||||
|
||||
export type WithDoubleDash<T> = T & { "--"?: string[] }
|
||||
type WithDoubleDash<T> = T & { "--"?: string[] }
|
||||
|
||||
export function cmd<T, U>(input: CommandModule<T, WithDoubleDash<U>>) {
|
||||
return input
|
||||
|
||||
@@ -7,13 +7,14 @@ import { Session } from "@/session/session"
|
||||
import type { MessageV2 } from "../../../session/message-v2"
|
||||
import { MessageID, PartID } from "../../../session/schema"
|
||||
import { ToolRegistry } from "@/tool/registry"
|
||||
import { Instance } from "../../../project/instance"
|
||||
import { Permission } from "../../../permission"
|
||||
import { iife } from "../../../util/iife"
|
||||
import { effectCmd, fail } from "../../effect-cmd"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import type { InstanceContext } from "@/project/instance"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
|
||||
export const AgentCommand = effectCmd({
|
||||
export const AgentCommand = cmd({
|
||||
command: "agent <name>",
|
||||
describe: "show agent configuration details",
|
||||
builder: (yargs) =>
|
||||
@@ -31,60 +32,60 @@ export const AgentCommand = effectCmd({
|
||||
type: "string",
|
||||
description: "Tool params as JSON or a JS object literal",
|
||||
}),
|
||||
handler: Effect.fn("Cli.debug.agent")(function* (args) {
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return
|
||||
return yield* run(args, ctx)
|
||||
}),
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const agentName = args.name as string
|
||||
const agent = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.get(agentName)))
|
||||
if (!agent) {
|
||||
process.stderr.write(
|
||||
`Agent ${agentName} not found, run '${basename(process.execPath)} agent list' to get an agent list` + EOL,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
const availableTools = await getAvailableTools(agent)
|
||||
const resolvedTools = await resolveTools(agent, availableTools)
|
||||
const toolID = args.tool as string | undefined
|
||||
if (toolID) {
|
||||
const tool = availableTools.find((item) => item.id === toolID)
|
||||
if (!tool) {
|
||||
process.stderr.write(`Tool ${toolID} not found for agent ${agentName}` + EOL)
|
||||
process.exit(1)
|
||||
}
|
||||
if (resolvedTools[toolID] === false) {
|
||||
process.stderr.write(`Tool ${toolID} is disabled for agent ${agentName}` + EOL)
|
||||
process.exit(1)
|
||||
}
|
||||
const params = parseToolParams(args.params as string | undefined)
|
||||
const ctx = await createToolContext(agent)
|
||||
const result = await tool.execute(params, ctx)
|
||||
process.stdout.write(JSON.stringify({ tool: toolID, input: params, result }, null, 2) + EOL)
|
||||
return
|
||||
}
|
||||
|
||||
const output = {
|
||||
...agent,
|
||||
tools: resolvedTools,
|
||||
}
|
||||
process.stdout.write(JSON.stringify(output, null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const run = Effect.fn("Cli.debug.agent.body")(function* (
|
||||
args: { name: string; tool?: string; params?: string },
|
||||
ctx: InstanceContext,
|
||||
) {
|
||||
const agentName = args.name
|
||||
const agent = yield* Agent.Service.use((svc) => svc.get(agentName))
|
||||
if (!agent) {
|
||||
process.stderr.write(
|
||||
`Agent ${agentName} not found, run '${basename(process.execPath)} agent list' to get an agent list` + EOL,
|
||||
)
|
||||
return yield* fail("", 1)
|
||||
}
|
||||
const availableTools = yield* getAvailableTools(agent)
|
||||
const resolvedTools = resolveTools(agent, availableTools)
|
||||
const toolID = args.tool
|
||||
if (toolID) {
|
||||
const tool = availableTools.find((item) => item.id === toolID)
|
||||
if (!tool) {
|
||||
process.stderr.write(`Tool ${toolID} not found for agent ${agentName}` + EOL)
|
||||
return yield* fail("", 1)
|
||||
}
|
||||
if (resolvedTools[toolID] === false) {
|
||||
process.stderr.write(`Tool ${toolID} is disabled for agent ${agentName}` + EOL)
|
||||
return yield* fail("", 1)
|
||||
}
|
||||
const params = parseToolParams(args.params)
|
||||
const toolCtx = yield* createToolContext(agent, ctx)
|
||||
const result = yield* tool.execute(params, toolCtx)
|
||||
process.stdout.write(JSON.stringify({ tool: toolID, input: params, result }, null, 2) + EOL)
|
||||
return
|
||||
}
|
||||
async function getAvailableTools(agent: Agent.Info) {
|
||||
return AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const model = agent.model ?? (yield* provider.defaultModel())
|
||||
return yield* registry.tools({
|
||||
...model,
|
||||
agent,
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const output = {
|
||||
...agent,
|
||||
tools: resolvedTools,
|
||||
}
|
||||
process.stdout.write(JSON.stringify(output, null, 2) + EOL)
|
||||
})
|
||||
|
||||
const getAvailableTools = Effect.fn("Cli.debug.agent.getAvailableTools")(function* (agent: Agent.Info) {
|
||||
const provider = yield* Provider.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const model = agent.model ?? (yield* provider.defaultModel())
|
||||
return yield* registry.tools({ ...model, agent })
|
||||
})
|
||||
|
||||
function resolveTools(agent: Agent.Info, availableTools: { id: string }[]) {
|
||||
async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnType<typeof getAvailableTools>>) {
|
||||
const disabled = Permission.disabled(
|
||||
availableTools.map((tool) => tool.id),
|
||||
agent.permission,
|
||||
@@ -122,38 +123,50 @@ function parseToolParams(input?: string) {
|
||||
return parsed as Record<string, unknown>
|
||||
}
|
||||
|
||||
const createToolContext = Effect.fn("Cli.debug.agent.createToolContext")(function* (
|
||||
agent: Agent.Info,
|
||||
ctx: InstanceContext,
|
||||
) {
|
||||
const sessionSvc = yield* Session.Service
|
||||
const session = yield* sessionSvc.create({ title: `Debug tool run (${agent.name})` })
|
||||
const messageID = MessageID.ascending()
|
||||
const model = agent.model
|
||||
? agent.model
|
||||
: yield* Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
return yield* provider.defaultModel()
|
||||
})
|
||||
const now = Date.now()
|
||||
const message: MessageV2.Assistant = {
|
||||
id: messageID,
|
||||
sessionID: session.id,
|
||||
role: "assistant",
|
||||
time: { created: now },
|
||||
parentID: messageID,
|
||||
modelID: model.modelID,
|
||||
providerID: model.providerID,
|
||||
mode: "debug",
|
||||
agent: agent.name,
|
||||
path: {
|
||||
cwd: ctx.directory,
|
||||
root: ctx.worktree,
|
||||
},
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
}
|
||||
yield* sessionSvc.updateMessage(message)
|
||||
async function createToolContext(agent: Agent.Info) {
|
||||
const { session, messageID } = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const result = yield* session.create({ title: `Debug tool run (${agent.name})` })
|
||||
const messageID = MessageID.ascending()
|
||||
const model = agent.model
|
||||
? agent.model
|
||||
: yield* Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
return yield* provider.defaultModel()
|
||||
})
|
||||
const now = Date.now()
|
||||
const message: MessageV2.Assistant = {
|
||||
id: messageID,
|
||||
sessionID: result.id,
|
||||
role: "assistant",
|
||||
time: {
|
||||
created: now,
|
||||
},
|
||||
parentID: messageID,
|
||||
modelID: model.modelID,
|
||||
providerID: model.providerID,
|
||||
mode: "debug",
|
||||
agent: agent.name,
|
||||
path: {
|
||||
cwd: Instance.directory,
|
||||
root: Instance.worktree,
|
||||
},
|
||||
cost: 0,
|
||||
tokens: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
reasoning: 0,
|
||||
cache: {
|
||||
read: 0,
|
||||
write: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
yield* session.updateMessage(message)
|
||||
return { session: result, messageID }
|
||||
}),
|
||||
)
|
||||
|
||||
const ruleset = Permission.merge(agent.permission, session.permission ?? [])
|
||||
|
||||
@@ -176,4 +189,4 @@ const createToolContext = Effect.fn("Cli.debug.agent.createToolContext")(functio
|
||||
})
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { EOL } from "os"
|
||||
import { Effect } from "effect"
|
||||
import { Config } from "@/config/config"
|
||||
import { effectCmd } from "../../effect-cmd"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
|
||||
export const ConfigCommand = effectCmd({
|
||||
export const ConfigCommand = cmd({
|
||||
command: "config",
|
||||
describe: "show resolved configuration",
|
||||
builder: (yargs) => yargs,
|
||||
handler: Effect.fn("Cli.debug.config")(function* () {
|
||||
const config = yield* Config.Service.use((cfg) => cfg.get())
|
||||
process.stdout.write(JSON.stringify(config, null, 2) + EOL)
|
||||
}),
|
||||
async handler() {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))
|
||||
process.stdout.write(JSON.stringify(config, null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { EOL } from "os"
|
||||
import { Effect } from "effect"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { File } from "../../../file"
|
||||
import { Ripgrep } from "@/file/ripgrep"
|
||||
import { effectCmd } from "../../effect-cmd"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
|
||||
const FileSearchCommand = effectCmd({
|
||||
const FileSearchCommand = cmd({
|
||||
command: "search <query>",
|
||||
describe: "search files by query",
|
||||
builder: (yargs) =>
|
||||
@@ -14,13 +14,15 @@ const FileSearchCommand = effectCmd({
|
||||
demandOption: true,
|
||||
description: "Search query",
|
||||
}),
|
||||
handler: Effect.fn("Cli.debug.file.search")(function* (args) {
|
||||
const results = yield* File.Service.use((svc) => svc.search({ query: args.query }))
|
||||
process.stdout.write(results.join(EOL) + EOL)
|
||||
}),
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const results = await AppRuntime.runPromise(File.Service.use((svc) => svc.search({ query: args.query })))
|
||||
process.stdout.write(results.join(EOL) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const FileReadCommand = effectCmd({
|
||||
const FileReadCommand = cmd({
|
||||
command: "read <path>",
|
||||
describe: "read file contents as JSON",
|
||||
builder: (yargs) =>
|
||||
@@ -29,23 +31,27 @@ const FileReadCommand = effectCmd({
|
||||
demandOption: true,
|
||||
description: "File path to read",
|
||||
}),
|
||||
handler: Effect.fn("Cli.debug.file.read")(function* (args) {
|
||||
const content = yield* File.Service.use((svc) => svc.read(args.path))
|
||||
process.stdout.write(JSON.stringify(content, null, 2) + EOL)
|
||||
}),
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const content = await AppRuntime.runPromise(File.Service.use((svc) => svc.read(args.path)))
|
||||
process.stdout.write(JSON.stringify(content, null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const FileStatusCommand = effectCmd({
|
||||
const FileStatusCommand = cmd({
|
||||
command: "status",
|
||||
describe: "show file status information",
|
||||
builder: (yargs) => yargs,
|
||||
handler: Effect.fn("Cli.debug.file.status")(function* () {
|
||||
const status = yield* File.Service.use((svc) => svc.status())
|
||||
process.stdout.write(JSON.stringify(status, null, 2) + EOL)
|
||||
}),
|
||||
async handler() {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const status = await AppRuntime.runPromise(File.Service.use((svc) => svc.status()))
|
||||
process.stdout.write(JSON.stringify(status, null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const FileListCommand = effectCmd({
|
||||
const FileListCommand = cmd({
|
||||
command: "list <path>",
|
||||
describe: "list files in a directory",
|
||||
builder: (yargs) =>
|
||||
@@ -54,13 +60,15 @@ const FileListCommand = effectCmd({
|
||||
demandOption: true,
|
||||
description: "File path to list",
|
||||
}),
|
||||
handler: Effect.fn("Cli.debug.file.list")(function* (args) {
|
||||
const files = yield* File.Service.use((svc) => svc.list(args.path))
|
||||
process.stdout.write(JSON.stringify(files, null, 2) + EOL)
|
||||
}),
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const files = await AppRuntime.runPromise(File.Service.use((svc) => svc.list(args.path)))
|
||||
process.stdout.write(JSON.stringify(files, null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const FileTreeCommand = effectCmd({
|
||||
const FileTreeCommand = cmd({
|
||||
command: "tree [dir]",
|
||||
describe: "show directory tree",
|
||||
builder: (yargs) =>
|
||||
@@ -69,10 +77,12 @@ const FileTreeCommand = effectCmd({
|
||||
description: "Directory to tree",
|
||||
default: process.cwd(),
|
||||
}),
|
||||
handler: Effect.fn("Cli.debug.file.tree")(function* (args) {
|
||||
const tree = yield* Effect.orDie(Ripgrep.Service.use((svc) => svc.tree({ cwd: args.dir, limit: 200 })))
|
||||
console.log(JSON.stringify(tree, null, 2))
|
||||
}),
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const tree = await AppRuntime.runPromise(Ripgrep.Service.use((svc) => svc.tree({ cwd: args.dir, limit: 200 })))
|
||||
console.log(JSON.stringify(tree, null, 2))
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const FileCommand = cmd({
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import os from "os"
|
||||
import { Duration, Effect } from "effect"
|
||||
import { Config } from "@/config/config"
|
||||
import { ConfigPlugin } from "@/config/plugin"
|
||||
import { effectCmd } from "../../effect-cmd"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
import { ConfigCommand } from "./config"
|
||||
import { FileCommand } from "./file"
|
||||
@@ -31,49 +25,20 @@ export const DebugCommand = cmd({
|
||||
.command(SnapshotCommand)
|
||||
.command(StartupCommand)
|
||||
.command(AgentCommand)
|
||||
.command(InfoCommand)
|
||||
.command(PathsCommand)
|
||||
.command(WaitCommand)
|
||||
.command({
|
||||
command: "wait",
|
||||
describe: "wait indefinitely (for debugging)",
|
||||
async handler() {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_000 * 60 * 60 * 24))
|
||||
})
|
||||
},
|
||||
})
|
||||
.demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
const WaitCommand = effectCmd({
|
||||
command: "wait",
|
||||
describe: "wait indefinitely (for debugging)",
|
||||
handler: Effect.fn("Cli.debug.wait")(function* () {
|
||||
yield* Effect.sleep(Duration.days(1))
|
||||
}),
|
||||
})
|
||||
|
||||
const InfoCommand = effectCmd({
|
||||
command: "info",
|
||||
describe: "show debug information",
|
||||
handler: Effect.fn("Cli.debug.info")(function* () {
|
||||
const config = yield* Config.Service.use((cfg) => cfg.get())
|
||||
const termProgram = process.env.TERM_PROGRAM
|
||||
? `${process.env.TERM_PROGRAM}${process.env.TERM_PROGRAM_VERSION ? ` ${process.env.TERM_PROGRAM_VERSION}` : ""}`
|
||||
: undefined
|
||||
const terminal = [termProgram, process.env.TERM].filter((item): item is string => Boolean(item)).join(" / ")
|
||||
|
||||
console.log(`opencode version: ${InstallationVersion}`)
|
||||
console.log(`os: ${os.type()} ${os.release()} ${os.arch()}`)
|
||||
console.log(`terminal: ${terminal || "unknown"}`)
|
||||
console.log("plugins:")
|
||||
if (Flag.OPENCODE_PURE) {
|
||||
console.log("external plugins disabled (--pure)")
|
||||
return
|
||||
}
|
||||
if (!config.plugin_origins?.length) {
|
||||
console.log("none")
|
||||
return
|
||||
}
|
||||
for (const plugin of config.plugin_origins) {
|
||||
console.log(`- ${ConfigPlugin.pluginSpecifier(plugin.spec)}`)
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
const PathsCommand = cmd({
|
||||
command: "paths",
|
||||
describe: "show global paths (data, config, cache, state)",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { LSP } from "@/lsp/lsp"
|
||||
import { AppRuntime } from "../../../effect/app-runtime"
|
||||
import { Effect } from "effect"
|
||||
import { effectCmd } from "../../effect-cmd"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { EOL } from "os"
|
||||
@@ -13,39 +14,47 @@ export const LSPCommand = cmd({
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
const DiagnosticsCommand = effectCmd({
|
||||
const DiagnosticsCommand = cmd({
|
||||
command: "diagnostics <file>",
|
||||
describe: "get diagnostics for a file",
|
||||
builder: (yargs) => yargs.positional("file", { type: "string", demandOption: true }),
|
||||
handler: Effect.fn("Cli.debug.lsp.diagnostics")(function* (args) {
|
||||
const out = yield* LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* lsp.touchFile(args.file, "full")
|
||||
return yield* lsp.diagnostics()
|
||||
}),
|
||||
)
|
||||
process.stdout.write(JSON.stringify(out, null, 2) + EOL)
|
||||
}),
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const out = await AppRuntime.runPromise(
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* lsp.touchFile(args.file, "full")
|
||||
return yield* lsp.diagnostics()
|
||||
}),
|
||||
),
|
||||
)
|
||||
process.stdout.write(JSON.stringify(out, null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const SymbolsCommand = effectCmd({
|
||||
export const SymbolsCommand = cmd({
|
||||
command: "symbols <query>",
|
||||
describe: "search workspace symbols",
|
||||
builder: (yargs) => yargs.positional("query", { type: "string", demandOption: true }),
|
||||
handler: Effect.fn("Cli.debug.lsp.symbols")(function* (args) {
|
||||
using _ = Log.Default.time("symbols")
|
||||
const results = yield* LSP.Service.use((lsp) => lsp.workspaceSymbol(args.query))
|
||||
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
|
||||
}),
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
using _ = Log.Default.time("symbols")
|
||||
const results = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.workspaceSymbol(args.query)))
|
||||
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const DocumentSymbolsCommand = effectCmd({
|
||||
export const DocumentSymbolsCommand = cmd({
|
||||
command: "document-symbols <uri>",
|
||||
describe: "get symbols from a document",
|
||||
builder: (yargs) => yargs.positional("uri", { type: "string", demandOption: true }),
|
||||
handler: Effect.fn("Cli.debug.lsp.documentSymbols")(function* (args) {
|
||||
using _ = Log.Default.time("document-symbols")
|
||||
const results = yield* LSP.Service.use((lsp) => lsp.documentSymbol(args.uri))
|
||||
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
|
||||
}),
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
using _ = Log.Default.time("document-symbols")
|
||||
const results = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.documentSymbol(args.uri)))
|
||||
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { EOL } from "os"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { AppRuntime } from "../../../effect/app-runtime"
|
||||
import { Ripgrep } from "../../../file/ripgrep"
|
||||
import { effectCmd } from "../../effect-cmd"
|
||||
import { Instance } from "../../../project/instance"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
|
||||
export const RipgrepCommand = cmd({
|
||||
command: "rg",
|
||||
@@ -12,22 +13,24 @@ export const RipgrepCommand = cmd({
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
const TreeCommand = effectCmd({
|
||||
const TreeCommand = cmd({
|
||||
command: "tree",
|
||||
describe: "show file tree using ripgrep",
|
||||
builder: (yargs) =>
|
||||
yargs.option("limit", {
|
||||
type: "number",
|
||||
}),
|
||||
handler: Effect.fn("Cli.debug.rg.tree")(function* (args) {
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return
|
||||
const tree = yield* Effect.orDie(Ripgrep.Service.use((svc) => svc.tree({ cwd: ctx.directory, limit: args.limit })))
|
||||
process.stdout.write(tree + EOL)
|
||||
}),
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const tree = await AppRuntime.runPromise(
|
||||
Ripgrep.Service.use((svc) => svc.tree({ cwd: Instance.directory, limit: args.limit })),
|
||||
)
|
||||
process.stdout.write(tree + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const FilesCommand = effectCmd({
|
||||
const FilesCommand = cmd({
|
||||
command: "files",
|
||||
describe: "list files using ripgrep",
|
||||
builder: (yargs) =>
|
||||
@@ -44,26 +47,29 @@ const FilesCommand = effectCmd({
|
||||
type: "number",
|
||||
description: "Limit number of results",
|
||||
}),
|
||||
handler: Effect.fn("Cli.debug.rg.files")(function* (args) {
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return
|
||||
const rg = yield* Ripgrep.Service
|
||||
const files = yield* rg
|
||||
.files({
|
||||
cwd: ctx.directory,
|
||||
glob: args.glob ? [args.glob] : undefined,
|
||||
})
|
||||
.pipe(
|
||||
Stream.take(args.limit ?? Infinity),
|
||||
Stream.runCollect,
|
||||
Effect.map((c) => [...c]),
|
||||
Effect.orDie,
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const files = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const rg = yield* Ripgrep.Service
|
||||
return yield* rg
|
||||
.files({
|
||||
cwd: Instance.directory,
|
||||
glob: args.glob ? [args.glob] : undefined,
|
||||
})
|
||||
.pipe(
|
||||
Stream.take(args.limit ?? Infinity),
|
||||
Stream.runCollect,
|
||||
Effect.map((c) => [...c]),
|
||||
)
|
||||
}),
|
||||
)
|
||||
process.stdout.write(files.join(EOL) + EOL)
|
||||
}),
|
||||
process.stdout.write(files.join(EOL) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const SearchCommand = effectCmd({
|
||||
const SearchCommand = cmd({
|
||||
command: "search <pattern>",
|
||||
describe: "search file contents using ripgrep",
|
||||
builder: (yargs) =>
|
||||
@@ -81,19 +87,19 @@ const SearchCommand = effectCmd({
|
||||
type: "number",
|
||||
description: "Limit number of results",
|
||||
}),
|
||||
handler: Effect.fn("Cli.debug.rg.search")(function* (args) {
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return
|
||||
const results = yield* Effect.orDie(
|
||||
Ripgrep.Service.use((svc) =>
|
||||
svc.search({
|
||||
cwd: ctx.directory,
|
||||
pattern: args.pattern,
|
||||
glob: args.glob as string[] | undefined,
|
||||
limit: args.limit,
|
||||
}),
|
||||
),
|
||||
)
|
||||
process.stdout.write(JSON.stringify(results.items, null, 2) + EOL)
|
||||
}),
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const results = await AppRuntime.runPromise(
|
||||
Ripgrep.Service.use((svc) =>
|
||||
svc.search({
|
||||
cwd: Instance.directory,
|
||||
pattern: args.pattern,
|
||||
glob: args.glob as string[] | undefined,
|
||||
limit: args.limit,
|
||||
}),
|
||||
),
|
||||
)
|
||||
process.stdout.write(JSON.stringify(results.items, null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import { EOL } from "os"
|
||||
import { Effect } from "effect"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Skill } from "../../../skill"
|
||||
import { effectCmd } from "../../effect-cmd"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
|
||||
export const SkillCommand = effectCmd({
|
||||
export const SkillCommand = cmd({
|
||||
command: "skill",
|
||||
describe: "list all available skills",
|
||||
builder: (yargs) => yargs,
|
||||
handler: Effect.fn("Cli.debug.skill")(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
const skills = yield* skill.all()
|
||||
process.stdout.write(JSON.stringify(skills, null, 2) + EOL)
|
||||
}),
|
||||
async handler() {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const skills = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
return yield* skill.all()
|
||||
}),
|
||||
)
|
||||
process.stdout.write(JSON.stringify(skills, null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Effect } from "effect"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Snapshot } from "../../../snapshot"
|
||||
import { effectCmd } from "../../effect-cmd"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
|
||||
export const SnapshotCommand = cmd({
|
||||
@@ -10,16 +10,17 @@ export const SnapshotCommand = cmd({
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
const TrackCommand = effectCmd({
|
||||
const TrackCommand = cmd({
|
||||
command: "track",
|
||||
describe: "track current snapshot state",
|
||||
handler: Effect.fn("Cli.debug.snapshot.track")(function* () {
|
||||
const out = yield* Snapshot.Service.use((svc) => svc.track())
|
||||
console.log(out)
|
||||
}),
|
||||
async handler() {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
console.log(await AppRuntime.runPromise(Snapshot.Service.use((svc) => svc.track())))
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const PatchCommand = effectCmd({
|
||||
const PatchCommand = cmd({
|
||||
command: "patch <hash>",
|
||||
describe: "show patch for a snapshot hash",
|
||||
builder: (yargs) =>
|
||||
@@ -28,13 +29,14 @@ const PatchCommand = effectCmd({
|
||||
description: "hash",
|
||||
demandOption: true,
|
||||
}),
|
||||
handler: Effect.fn("Cli.debug.snapshot.patch")(function* (args) {
|
||||
const out = yield* Snapshot.Service.use((svc) => svc.patch(args.hash))
|
||||
console.log(out)
|
||||
}),
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
console.log(await AppRuntime.runPromise(Snapshot.Service.use((svc) => svc.patch(args.hash))))
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const DiffCommand = effectCmd({
|
||||
const DiffCommand = cmd({
|
||||
command: "diff <hash>",
|
||||
describe: "show diff for a snapshot hash",
|
||||
builder: (yargs) =>
|
||||
@@ -43,8 +45,9 @@ const DiffCommand = effectCmd({
|
||||
description: "hash",
|
||||
demandOption: true,
|
||||
}),
|
||||
handler: Effect.fn("Cli.debug.snapshot.diff")(function* (args) {
|
||||
const out = yield* Snapshot.Service.use((svc) => svc.diff(args.hash))
|
||||
console.log(out)
|
||||
}),
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
console.log(await AppRuntime.runPromise(Snapshot.Service.use((svc) => svc.diff(args.hash))))
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { Argv } from "yargs"
|
||||
import { Session } from "@/session/session"
|
||||
import { MessageV2 } from "../../session/message-v2"
|
||||
import { SessionID } from "../../session/schema"
|
||||
import { effectCmd, fail } from "../effect-cmd"
|
||||
import { cmd } from "./cmd"
|
||||
import { bootstrap } from "../bootstrap"
|
||||
import { UI } from "../ui"
|
||||
import * as prompts from "@clack/prompts"
|
||||
import { EOL } from "os"
|
||||
import { Effect } from "effect"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
|
||||
function redact(kind: string, id: string, value: string) {
|
||||
return value.trim() ? `[redacted:${kind}:${id}]` : value
|
||||
@@ -218,11 +220,11 @@ function sanitize(data: { info: Session.Info; messages: MessageV2.WithParts[] })
|
||||
}
|
||||
}
|
||||
|
||||
export const ExportCommand = effectCmd({
|
||||
export const ExportCommand = cmd({
|
||||
command: "export [sessionID]",
|
||||
describe: "export session data as JSON",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
builder: (yargs: Argv) => {
|
||||
return yargs
|
||||
.positional("sessionID", {
|
||||
describe: "session id to export",
|
||||
type: "string",
|
||||
@@ -230,62 +232,72 @@ export const ExportCommand = effectCmd({
|
||||
.option("sanitize", {
|
||||
describe: "redact sensitive transcript and file data",
|
||||
type: "boolean",
|
||||
}),
|
||||
handler: Effect.fn("Cli.export")(function* (args) {
|
||||
return yield* run(args)
|
||||
}),
|
||||
})
|
||||
|
||||
const run = Effect.fn("Cli.export.body")(function* (args: { sessionID?: string; sanitize?: boolean }) {
|
||||
const svc = yield* Session.Service
|
||||
let sessionID = args.sessionID ? SessionID.make(args.sessionID) : undefined
|
||||
process.stderr.write(`Exporting session: ${sessionID ?? "latest"}\n`)
|
||||
|
||||
if (!sessionID) {
|
||||
UI.empty()
|
||||
prompts.intro("Export session", { output: process.stderr })
|
||||
|
||||
const sessions = yield* svc.list()
|
||||
|
||||
if (sessions.length === 0) {
|
||||
prompts.log.error("No sessions found", { output: process.stderr })
|
||||
prompts.outro("Done", { output: process.stderr })
|
||||
return
|
||||
}
|
||||
|
||||
sessions.sort((a, b) => b.time.updated - a.time.updated)
|
||||
|
||||
const selectedSession = yield* Effect.promise(() =>
|
||||
prompts.autocomplete({
|
||||
message: "Select session to export",
|
||||
maxItems: 10,
|
||||
options: sessions.map((session) => ({
|
||||
label: session.title,
|
||||
value: session.id,
|
||||
hint: `${new Date(session.time.updated).toLocaleString()} • ${session.id.slice(-8)}`,
|
||||
})),
|
||||
output: process.stderr,
|
||||
}),
|
||||
)
|
||||
|
||||
if (prompts.isCancel(selectedSession)) {
|
||||
return yield* Effect.die(new UI.CancelledError())
|
||||
}
|
||||
|
||||
sessionID = selectedSession
|
||||
|
||||
prompts.outro("Exporting session...", { output: process.stderr })
|
||||
}
|
||||
|
||||
// Match legacy try/catch — catches both typed failures and defects
|
||||
// (Session.Service.get throws NotFoundError as a defect, not a typed E).
|
||||
return yield* Effect.gen(function* () {
|
||||
const sessionInfo = yield* svc.get(sessionID!)
|
||||
const messages = yield* svc.messages({ sessionID: sessionInfo.id })
|
||||
|
||||
const exportData = { info: sessionInfo, messages }
|
||||
|
||||
process.stdout.write(JSON.stringify(args.sanitize ? sanitize(exportData) : exportData, null, 2))
|
||||
process.stdout.write(EOL)
|
||||
}).pipe(Effect.catchCause(() => fail(`Session not found: ${sessionID!}`)))
|
||||
})
|
||||
},
|
||||
handler: async (args) => {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
let sessionID = args.sessionID ? SessionID.make(args.sessionID) : undefined
|
||||
process.stderr.write(`Exporting session: ${sessionID ?? "latest"}\n`)
|
||||
|
||||
if (!sessionID) {
|
||||
UI.empty()
|
||||
prompts.intro("Export session", {
|
||||
output: process.stderr,
|
||||
})
|
||||
|
||||
const sessions = await AppRuntime.runPromise(Session.Service.use((svc) => svc.list()))
|
||||
|
||||
if (sessions.length === 0) {
|
||||
prompts.log.error("No sessions found", {
|
||||
output: process.stderr,
|
||||
})
|
||||
prompts.outro("Done", {
|
||||
output: process.stderr,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
sessions.sort((a, b) => b.time.updated - a.time.updated)
|
||||
|
||||
const selectedSession = await prompts.autocomplete({
|
||||
message: "Select session to export",
|
||||
maxItems: 10,
|
||||
options: sessions.map((session) => ({
|
||||
label: session.title,
|
||||
value: session.id,
|
||||
hint: `${new Date(session.time.updated).toLocaleString()} • ${session.id.slice(-8)}`,
|
||||
})),
|
||||
output: process.stderr,
|
||||
})
|
||||
|
||||
if (prompts.isCancel(selectedSession)) {
|
||||
throw new UI.CancelledError()
|
||||
}
|
||||
|
||||
sessionID = selectedSession
|
||||
|
||||
prompts.outro("Exporting session...", {
|
||||
output: process.stderr,
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionInfo = await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID!)))
|
||||
const messages = await AppRuntime.runPromise(
|
||||
Session.Service.use((svc) => svc.messages({ sessionID: sessionInfo.id })),
|
||||
)
|
||||
|
||||
const exportData = {
|
||||
info: sessionInfo,
|
||||
messages,
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify(args.sanitize ? sanitize(exportData) : exportData, null, 2))
|
||||
process.stdout.write(EOL)
|
||||
} catch {
|
||||
UI.error(`Session not found: ${sessionID!}`)
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,28 +1,22 @@
|
||||
import { Server } from "../../server/server"
|
||||
import { PublicApi } from "../../server/routes/instance/httpapi/public"
|
||||
import type { CommandModule } from "yargs"
|
||||
import { OpenApi } from "effect/unstable/httpapi"
|
||||
|
||||
type Args = {
|
||||
httpapi: boolean
|
||||
hono: boolean
|
||||
}
|
||||
|
||||
export const GenerateCommand = {
|
||||
command: "generate",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.option("httpapi", {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
description:
|
||||
"Generate OpenAPI from the Effect HttpApi contract (default; flag retained for backwards compatibility)",
|
||||
})
|
||||
.option("hono", {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
description: "Generate OpenAPI from the legacy Hono backend (parity-diff only; will be removed)",
|
||||
}),
|
||||
yargs.option("httpapi", {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
description: "Generate OpenAPI from the experimental Effect HttpApi contract",
|
||||
}),
|
||||
handler: async (args) => {
|
||||
const specs = args.hono ? await Server.openapiHono() : await Server.openapi()
|
||||
const specs = args.httpapi ? OpenApi.fromApi(PublicApi) : await Server.openapi()
|
||||
for (const item of Object.values(specs.paths)) {
|
||||
for (const method of ["get", "post", "put", "delete", "patch"] as const) {
|
||||
const operation = item[method]
|
||||
|
||||
@@ -18,9 +18,9 @@ import type {
|
||||
} from "@octokit/webhooks-types"
|
||||
import { UI } from "../ui"
|
||||
import { cmd } from "./cmd"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
import { ModelsDev } from "@/provider/models"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { bootstrap } from "../bootstrap"
|
||||
import { SessionShare } from "@/share/session"
|
||||
import { Session } from "@/session/session"
|
||||
import type { SessionID } from "../../session/schema"
|
||||
@@ -199,192 +199,191 @@ export const GithubCommand = cmd({
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
export const GithubInstallCommand = effectCmd({
|
||||
export const GithubInstallCommand = cmd({
|
||||
command: "install",
|
||||
describe: "install the GitHub agent",
|
||||
handler: Effect.fn("Cli.github.install")(function* () {
|
||||
const maybeCtx = yield* InstanceRef
|
||||
if (!maybeCtx) return yield* Effect.die("InstanceRef not provided")
|
||||
const ctx = maybeCtx
|
||||
yield* Effect.promise(async () => {
|
||||
{
|
||||
UI.empty()
|
||||
prompts.intro("Install GitHub agent")
|
||||
const app = await getAppInfo()
|
||||
await installGitHubApp()
|
||||
async handler() {
|
||||
await Instance.provide({
|
||||
directory: process.cwd(),
|
||||
async fn() {
|
||||
{
|
||||
UI.empty()
|
||||
prompts.intro("Install GitHub agent")
|
||||
const app = await getAppInfo()
|
||||
await installGitHubApp()
|
||||
|
||||
const providers = await AppRuntime.runPromise(ModelsDev.Service.use((s) => s.get())).then((p) => {
|
||||
// TODO: add guide for copilot, for now just hide it
|
||||
delete p["github-copilot"]
|
||||
return p
|
||||
})
|
||||
|
||||
const provider = await promptProvider()
|
||||
const model = await promptModel()
|
||||
//const key = await promptKey()
|
||||
|
||||
await addWorkflowFiles()
|
||||
printNextSteps()
|
||||
|
||||
function printNextSteps() {
|
||||
let step2
|
||||
if (provider === "amazon-bedrock") {
|
||||
step2 =
|
||||
"Configure OIDC in AWS - https://docs.github.com/en/actions/how-tos/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services"
|
||||
} else {
|
||||
step2 = [
|
||||
` 2. Add the following secrets in org or repo (${app.owner}/${app.repo}) settings`,
|
||||
"",
|
||||
...providers[provider].env.map((e) => ` - ${e}`),
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
prompts.outro(
|
||||
[
|
||||
"Next steps:",
|
||||
"",
|
||||
` 1. Commit the \`${WORKFLOW_FILE}\` file and push`,
|
||||
step2,
|
||||
"",
|
||||
" 3. Go to a GitHub issue and comment `/oc summarize` to see the agent in action",
|
||||
"",
|
||||
" Learn more about the GitHub agent - https://opencode.ai/docs/github/#usage-examples",
|
||||
].join("\n"),
|
||||
)
|
||||
}
|
||||
|
||||
async function getAppInfo() {
|
||||
const project = ctx.project
|
||||
if (project.vcs !== "git") {
|
||||
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
|
||||
throw new UI.CancelledError()
|
||||
}
|
||||
|
||||
// Get repo info
|
||||
const info = await AppRuntime.runPromise(
|
||||
Git.Service.use((git) => git.run(["remote", "get-url", "origin"], { cwd: ctx.worktree })),
|
||||
).then((x) => x.text().trim())
|
||||
const parsed = parseGitHubRemote(info)
|
||||
if (!parsed) {
|
||||
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
|
||||
throw new UI.CancelledError()
|
||||
}
|
||||
return { owner: parsed.owner, repo: parsed.repo, root: ctx.worktree }
|
||||
}
|
||||
|
||||
async function promptProvider() {
|
||||
const priority: Record<string, number> = {
|
||||
opencode: 0,
|
||||
anthropic: 1,
|
||||
openai: 2,
|
||||
google: 3,
|
||||
}
|
||||
let provider = await prompts.select({
|
||||
message: "Select provider",
|
||||
maxItems: 8,
|
||||
options: pipe(
|
||||
providers,
|
||||
values(),
|
||||
sortBy(
|
||||
(x) => priority[x.id] ?? 99,
|
||||
(x) => x.name ?? x.id,
|
||||
),
|
||||
map((x) => ({
|
||||
label: x.name,
|
||||
value: x.id,
|
||||
hint: priority[x.id] === 0 ? "recommended" : undefined,
|
||||
})),
|
||||
),
|
||||
const providers = await AppRuntime.runPromise(ModelsDev.Service.use((s) => s.get())).then((p) => {
|
||||
// TODO: add guide for copilot, for now just hide it
|
||||
delete p["github-copilot"]
|
||||
return p
|
||||
})
|
||||
|
||||
if (prompts.isCancel(provider)) throw new UI.CancelledError()
|
||||
const provider = await promptProvider()
|
||||
const model = await promptModel()
|
||||
//const key = await promptKey()
|
||||
|
||||
return provider
|
||||
}
|
||||
await addWorkflowFiles()
|
||||
printNextSteps()
|
||||
|
||||
async function promptModel() {
|
||||
const providerData = providers[provider]!
|
||||
|
||||
const model = await prompts.select({
|
||||
message: "Select model",
|
||||
maxItems: 8,
|
||||
options: pipe(
|
||||
providerData.models,
|
||||
values(),
|
||||
sortBy((x) => x.name ?? x.id),
|
||||
map((x) => ({
|
||||
label: x.name ?? x.id,
|
||||
value: x.id,
|
||||
})),
|
||||
),
|
||||
})
|
||||
|
||||
if (prompts.isCancel(model)) throw new UI.CancelledError()
|
||||
return model
|
||||
}
|
||||
|
||||
async function installGitHubApp() {
|
||||
const s = prompts.spinner()
|
||||
s.start("Installing GitHub app")
|
||||
|
||||
// Get installation
|
||||
const installation = await getInstallation()
|
||||
if (installation) return s.stop("GitHub app already installed")
|
||||
|
||||
// Open browser
|
||||
const url = "https://github.com/apps/opencode-agent"
|
||||
const command =
|
||||
process.platform === "darwin"
|
||||
? `open "${url}"`
|
||||
: process.platform === "win32"
|
||||
? `start "" "${url}"`
|
||||
: `xdg-open "${url}"`
|
||||
|
||||
exec(command, (error) => {
|
||||
if (error) {
|
||||
prompts.log.warn(`Could not open browser. Please visit: ${url}`)
|
||||
function printNextSteps() {
|
||||
let step2
|
||||
if (provider === "amazon-bedrock") {
|
||||
step2 =
|
||||
"Configure OIDC in AWS - https://docs.github.com/en/actions/how-tos/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services"
|
||||
} else {
|
||||
step2 = [
|
||||
` 2. Add the following secrets in org or repo (${app.owner}/${app.repo}) settings`,
|
||||
"",
|
||||
...providers[provider].env.map((e) => ` - ${e}`),
|
||||
].join("\n")
|
||||
}
|
||||
})
|
||||
|
||||
// Wait for installation
|
||||
s.message("Waiting for GitHub app to be installed")
|
||||
const MAX_RETRIES = 120
|
||||
let retries = 0
|
||||
do {
|
||||
const installation = await getInstallation()
|
||||
if (installation) break
|
||||
prompts.outro(
|
||||
[
|
||||
"Next steps:",
|
||||
"",
|
||||
` 1. Commit the \`${WORKFLOW_FILE}\` file and push`,
|
||||
step2,
|
||||
"",
|
||||
" 3. Go to a GitHub issue and comment `/oc summarize` to see the agent in action",
|
||||
"",
|
||||
" Learn more about the GitHub agent - https://opencode.ai/docs/github/#usage-examples",
|
||||
].join("\n"),
|
||||
)
|
||||
}
|
||||
|
||||
if (retries > MAX_RETRIES) {
|
||||
s.stop(
|
||||
`Failed to detect GitHub app installation. Make sure to install the app for the \`${app.owner}/${app.repo}\` repository.`,
|
||||
)
|
||||
async function getAppInfo() {
|
||||
const project = Instance.project
|
||||
if (project.vcs !== "git") {
|
||||
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
|
||||
throw new UI.CancelledError()
|
||||
}
|
||||
|
||||
retries++
|
||||
await sleep(1000)
|
||||
} while (true) // oxlint-disable-line no-constant-condition
|
||||
|
||||
s.stop("Installed GitHub app")
|
||||
|
||||
async function getInstallation() {
|
||||
return await fetch(
|
||||
`https://api.opencode.ai/get_github_app_installation?owner=${app.owner}&repo=${app.repo}`,
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.then((data) => data.installation)
|
||||
// Get repo info
|
||||
const info = await AppRuntime.runPromise(
|
||||
Git.Service.use((git) => git.run(["remote", "get-url", "origin"], { cwd: Instance.worktree })),
|
||||
).then((x) => x.text().trim())
|
||||
const parsed = parseGitHubRemote(info)
|
||||
if (!parsed) {
|
||||
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
|
||||
throw new UI.CancelledError()
|
||||
}
|
||||
return { owner: parsed.owner, repo: parsed.repo, root: Instance.worktree }
|
||||
}
|
||||
}
|
||||
|
||||
async function addWorkflowFiles() {
|
||||
const envStr =
|
||||
provider === "amazon-bedrock"
|
||||
? ""
|
||||
: `\n env:${providers[provider].env.map((e) => `\n ${e}: \${{ secrets.${e} }}`).join("")}`
|
||||
async function promptProvider() {
|
||||
const priority: Record<string, number> = {
|
||||
opencode: 0,
|
||||
anthropic: 1,
|
||||
openai: 2,
|
||||
google: 3,
|
||||
}
|
||||
let provider = await prompts.select({
|
||||
message: "Select provider",
|
||||
maxItems: 8,
|
||||
options: pipe(
|
||||
providers,
|
||||
values(),
|
||||
sortBy(
|
||||
(x) => priority[x.id] ?? 99,
|
||||
(x) => x.name ?? x.id,
|
||||
),
|
||||
map((x) => ({
|
||||
label: x.name,
|
||||
value: x.id,
|
||||
hint: priority[x.id] === 0 ? "recommended" : undefined,
|
||||
})),
|
||||
),
|
||||
})
|
||||
|
||||
await Filesystem.write(
|
||||
path.join(app.root, WORKFLOW_FILE),
|
||||
`name: opencode
|
||||
if (prompts.isCancel(provider)) throw new UI.CancelledError()
|
||||
|
||||
return provider
|
||||
}
|
||||
|
||||
async function promptModel() {
|
||||
const providerData = providers[provider]!
|
||||
|
||||
const model = await prompts.select({
|
||||
message: "Select model",
|
||||
maxItems: 8,
|
||||
options: pipe(
|
||||
providerData.models,
|
||||
values(),
|
||||
sortBy((x) => x.name ?? x.id),
|
||||
map((x) => ({
|
||||
label: x.name ?? x.id,
|
||||
value: x.id,
|
||||
})),
|
||||
),
|
||||
})
|
||||
|
||||
if (prompts.isCancel(model)) throw new UI.CancelledError()
|
||||
return model
|
||||
}
|
||||
|
||||
async function installGitHubApp() {
|
||||
const s = prompts.spinner()
|
||||
s.start("Installing GitHub app")
|
||||
|
||||
// Get installation
|
||||
const installation = await getInstallation()
|
||||
if (installation) return s.stop("GitHub app already installed")
|
||||
|
||||
// Open browser
|
||||
const url = "https://github.com/apps/opencode-agent"
|
||||
const command =
|
||||
process.platform === "darwin"
|
||||
? `open "${url}"`
|
||||
: process.platform === "win32"
|
||||
? `start "" "${url}"`
|
||||
: `xdg-open "${url}"`
|
||||
|
||||
exec(command, (error) => {
|
||||
if (error) {
|
||||
prompts.log.warn(`Could not open browser. Please visit: ${url}`)
|
||||
}
|
||||
})
|
||||
|
||||
// Wait for installation
|
||||
s.message("Waiting for GitHub app to be installed")
|
||||
const MAX_RETRIES = 120
|
||||
let retries = 0
|
||||
do {
|
||||
const installation = await getInstallation()
|
||||
if (installation) break
|
||||
|
||||
if (retries > MAX_RETRIES) {
|
||||
s.stop(
|
||||
`Failed to detect GitHub app installation. Make sure to install the app for the \`${app.owner}/${app.repo}\` repository.`,
|
||||
)
|
||||
throw new UI.CancelledError()
|
||||
}
|
||||
|
||||
retries++
|
||||
await sleep(1000)
|
||||
} while (true) // oxlint-disable-line no-constant-condition
|
||||
|
||||
s.stop("Installed GitHub app")
|
||||
|
||||
async function getInstallation() {
|
||||
return await fetch(
|
||||
`https://api.opencode.ai/get_github_app_installation?owner=${app.owner}&repo=${app.repo}`,
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.then((data) => data.installation)
|
||||
}
|
||||
}
|
||||
|
||||
async function addWorkflowFiles() {
|
||||
const envStr =
|
||||
provider === "amazon-bedrock"
|
||||
? ""
|
||||
: `\n env:${providers[provider].env.map((e) => `\n ${e}: \${{ secrets.${e} }}`).join("")}`
|
||||
|
||||
await Filesystem.write(
|
||||
path.join(app.root, WORKFLOW_FILE),
|
||||
`name: opencode
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
@@ -415,16 +414,17 @@ jobs:
|
||||
uses: anomalyco/opencode/github@latest${envStr}
|
||||
with:
|
||||
model: ${provider}/${model}`,
|
||||
)
|
||||
)
|
||||
|
||||
prompts.log.success(`Added workflow file: "${WORKFLOW_FILE}"`)
|
||||
prompts.log.success(`Added workflow file: "${WORKFLOW_FILE}"`)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
export const GithubRunCommand = effectCmd({
|
||||
export const GithubRunCommand = cmd({
|
||||
command: "run",
|
||||
describe: "run the GitHub agent",
|
||||
builder: (yargs) =>
|
||||
@@ -437,10 +437,8 @@ export const GithubRunCommand = effectCmd({
|
||||
type: "string",
|
||||
describe: "GitHub personal access token (github_pat_********)",
|
||||
}),
|
||||
handler: Effect.fn("Cli.github.run")(function* (args) {
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return yield* Effect.die("InstanceRef not provided")
|
||||
yield* Effect.promise(async () => {
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const isMock = args.token || args.event
|
||||
|
||||
const context = isMock ? (JSON.parse(args.event!) as Context) : github.context
|
||||
@@ -503,21 +501,21 @@ export const GithubRunCommand = effectCmd({
|
||||
: "issue"
|
||||
: undefined
|
||||
const gitText = async (args: string[]) => {
|
||||
const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: ctx.worktree })))
|
||||
const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: Instance.worktree })))
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
|
||||
}
|
||||
return result.text().trim()
|
||||
}
|
||||
const gitRun = async (args: string[]) => {
|
||||
const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: ctx.worktree })))
|
||||
const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: Instance.worktree })))
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
|
||||
}
|
||||
return result
|
||||
}
|
||||
const gitStatus = (args: string[]) =>
|
||||
AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: ctx.worktree })))
|
||||
AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: Instance.worktree })))
|
||||
const commitChanges = async (summary: string, actor?: string) => {
|
||||
const args = ["commit", "-m", summary]
|
||||
if (actor) args.push("-m", `Co-authored-by: ${actor} <${actor}@users.noreply.github.com>`)
|
||||
@@ -881,7 +879,7 @@ export const GithubRunCommand = effectCmd({
|
||||
function subscribeSessionEvents() {
|
||||
const TOOL: Record<string, [string, string]> = {
|
||||
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
|
||||
bash: ["Shell", UI.Style.TEXT_DANGER_BOLD],
|
||||
bash: ["Bash", UI.Style.TEXT_DANGER_BOLD],
|
||||
edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD],
|
||||
glob: ["Glob", UI.Style.TEXT_INFO_BOLD],
|
||||
grep: ["Grep", UI.Style.TEXT_INFO_BOLD],
|
||||
@@ -1647,5 +1645,5 @@ query($owner: String!, $repo: String!, $number: Int!) {
|
||||
})
|
||||
}
|
||||
})
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import type { Argv } from "yargs"
|
||||
import type { Session as SDKSession, Message, Part } from "@opencode-ai/sdk/v2"
|
||||
import { Session } from "@/session/session"
|
||||
import { MessageV2 } from "../../session/message-v2"
|
||||
import { CliError, effectCmd } from "../effect-cmd"
|
||||
import { cmd } from "./cmd"
|
||||
import { bootstrap } from "../bootstrap"
|
||||
import { Database } from "@/storage/db"
|
||||
import { SessionTable, MessageTable, PartTable } from "../../session/session.sql"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { Instance } from "../../project/instance"
|
||||
import { ShareNext } from "@/share/share-next"
|
||||
import { EOL } from "os"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Schema } from "effect"
|
||||
|
||||
const decodeMessageInfo = Schema.decodeUnknownSync(MessageV2.Info)
|
||||
const decodePart = Schema.decodeUnknownSync(MessageV2.Part)
|
||||
@@ -75,143 +78,135 @@ export function transformShareData(shareData: ShareData[]): {
|
||||
}
|
||||
}
|
||||
|
||||
type ExportData = { info: SDKSession; messages: Array<{ info: Message; parts: Part[] }> }
|
||||
|
||||
export const ImportCommand = effectCmd({
|
||||
export const ImportCommand = cmd({
|
||||
command: "import <file>",
|
||||
describe: "import session data from JSON file or URL",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("file", {
|
||||
builder: (yargs: Argv) => {
|
||||
return yargs.positional("file", {
|
||||
describe: "path to JSON file or share URL",
|
||||
type: "string",
|
||||
demandOption: true,
|
||||
}),
|
||||
handler: Effect.fn("Cli.import")(function* (args) {
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return yield* Effect.die("InstanceRef not provided")
|
||||
return yield* runImport(args.file, ctx.project.id)
|
||||
}),
|
||||
})
|
||||
|
||||
const runImport = Effect.fn("Cli.import.body")(function* (file: string, projectID: string) {
|
||||
const share = yield* ShareNext.Service
|
||||
|
||||
let exportData: ExportData | undefined
|
||||
|
||||
const isUrl = file.startsWith("http://") || file.startsWith("https://")
|
||||
|
||||
if (isUrl) {
|
||||
const slug = parseShareUrl(file)
|
||||
if (!slug) {
|
||||
const baseUrl = yield* Effect.orDie(share.url())
|
||||
process.stdout.write(`Invalid URL format. Expected: ${baseUrl}/share/<slug>`)
|
||||
process.stdout.write(EOL)
|
||||
return
|
||||
}
|
||||
|
||||
const baseUrl = new URL(file).origin
|
||||
const req = yield* Effect.orDie(share.request())
|
||||
const headers = shouldAttachShareAuthHeaders(file, req.baseUrl) ? req.headers : {}
|
||||
|
||||
const tryFetch = (url: string) =>
|
||||
Effect.tryPromise({
|
||||
try: () => fetch(url, { headers }),
|
||||
catch: (e) =>
|
||||
new CliError({
|
||||
message: `Failed to fetch share data: ${e instanceof Error ? e.message : String(e)}`,
|
||||
}),
|
||||
})
|
||||
|
||||
const dataPath = req.api.data(slug)
|
||||
let response = yield* tryFetch(`${baseUrl}${dataPath}`)
|
||||
|
||||
if (!response.ok && dataPath !== `/api/share/${slug}/data`) {
|
||||
response = yield* tryFetch(`${baseUrl}/api/share/${slug}/data`)
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
process.stdout.write(`Failed to fetch share data: ${response.statusText}`)
|
||||
process.stdout.write(EOL)
|
||||
return
|
||||
}
|
||||
|
||||
const shareData = yield* Effect.tryPromise({
|
||||
try: () => response.json() as Promise<ShareData[]>,
|
||||
catch: () => new CliError({ message: "Share data was not valid JSON" }),
|
||||
})
|
||||
const transformed = transformShareData(shareData)
|
||||
},
|
||||
handler: async (args) => {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
let exportData:
|
||||
| {
|
||||
info: SDKSession
|
||||
messages: Array<{
|
||||
info: Message
|
||||
parts: Part[]
|
||||
}>
|
||||
}
|
||||
| undefined
|
||||
|
||||
if (!transformed) {
|
||||
process.stdout.write(`Share not found or empty: ${slug}`)
|
||||
process.stdout.write(EOL)
|
||||
return
|
||||
}
|
||||
const isUrl = args.file.startsWith("http://") || args.file.startsWith("https://")
|
||||
|
||||
exportData = transformed
|
||||
} else {
|
||||
exportData = yield* Effect.promise(() =>
|
||||
Filesystem.readJson<NonNullable<typeof exportData>>(file).catch(() => undefined),
|
||||
)
|
||||
if (!exportData) {
|
||||
process.stdout.write(`File not found: ${file}`)
|
||||
process.stdout.write(EOL)
|
||||
return
|
||||
}
|
||||
}
|
||||
if (isUrl) {
|
||||
const slug = parseShareUrl(args.file)
|
||||
if (!slug) {
|
||||
const baseUrl = await AppRuntime.runPromise(ShareNext.Service.use((svc) => svc.url()))
|
||||
process.stdout.write(`Invalid URL format. Expected: ${baseUrl}/share/<slug>`)
|
||||
process.stdout.write(EOL)
|
||||
return
|
||||
}
|
||||
|
||||
if (!exportData) {
|
||||
process.stdout.write(`Failed to read session data`)
|
||||
process.stdout.write(EOL)
|
||||
return
|
||||
}
|
||||
const parsed = new URL(args.file)
|
||||
const baseUrl = parsed.origin
|
||||
const req = await AppRuntime.runPromise(ShareNext.Service.use((svc) => svc.request()))
|
||||
const headers = shouldAttachShareAuthHeaders(args.file, req.baseUrl) ? req.headers : {}
|
||||
|
||||
const info = Schema.decodeUnknownSync(Session.Info)({
|
||||
...exportData.info,
|
||||
projectID,
|
||||
}) as Session.Info
|
||||
const row = Session.toRow(info)
|
||||
Database.use((db) =>
|
||||
db
|
||||
.insert(SessionTable)
|
||||
.values(row)
|
||||
.onConflictDoUpdate({ target: SessionTable.id, set: { project_id: row.project_id } })
|
||||
.run(),
|
||||
)
|
||||
|
||||
for (const msg of exportData.messages) {
|
||||
const msgInfo = decodeMessageInfo(msg.info) as MessageV2.Info
|
||||
const { id, sessionID: _, ...msgData } = msgInfo
|
||||
Database.use((db) =>
|
||||
db
|
||||
.insert(MessageTable)
|
||||
.values({
|
||||
id,
|
||||
session_id: row.id,
|
||||
time_created: msgInfo.time?.created ?? Date.now(),
|
||||
data: msgData,
|
||||
const dataPath = req.api.data(slug)
|
||||
let response = await fetch(`${baseUrl}${dataPath}`, {
|
||||
headers,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.run(),
|
||||
)
|
||||
|
||||
for (const part of msg.parts) {
|
||||
const partInfo = decodePart(part) as MessageV2.Part
|
||||
const { id: partId, sessionID: _s, messageID, ...partData } = partInfo
|
||||
if (!response.ok && dataPath !== `/api/share/${slug}/data`) {
|
||||
response = await fetch(`${baseUrl}/api/share/${slug}/data`, {
|
||||
headers,
|
||||
})
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
process.stdout.write(`Failed to fetch share data: ${response.statusText}`)
|
||||
process.stdout.write(EOL)
|
||||
return
|
||||
}
|
||||
|
||||
const shareData: ShareData[] = await response.json()
|
||||
const transformed = transformShareData(shareData)
|
||||
|
||||
if (!transformed) {
|
||||
process.stdout.write(`Share not found or empty: ${slug}`)
|
||||
process.stdout.write(EOL)
|
||||
return
|
||||
}
|
||||
|
||||
exportData = transformed
|
||||
} else {
|
||||
exportData = await Filesystem.readJson<NonNullable<typeof exportData>>(args.file).catch(() => undefined)
|
||||
if (!exportData) {
|
||||
process.stdout.write(`File not found: ${args.file}`)
|
||||
process.stdout.write(EOL)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!exportData) {
|
||||
process.stdout.write(`Failed to read session data`)
|
||||
process.stdout.write(EOL)
|
||||
return
|
||||
}
|
||||
|
||||
const info = Schema.decodeUnknownSync(Session.Info)({
|
||||
...exportData.info,
|
||||
projectID: Instance.project.id,
|
||||
}) as Session.Info
|
||||
const row = Session.toRow(info)
|
||||
Database.use((db) =>
|
||||
db
|
||||
.insert(PartTable)
|
||||
.values({
|
||||
id: partId,
|
||||
message_id: messageID,
|
||||
session_id: row.id,
|
||||
data: partData,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.insert(SessionTable)
|
||||
.values(row)
|
||||
.onConflictDoUpdate({ target: SessionTable.id, set: { project_id: row.project_id } })
|
||||
.run(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write(`Imported session: ${exportData.info.id}`)
|
||||
process.stdout.write(EOL)
|
||||
for (const msg of exportData.messages) {
|
||||
const msgInfo = decodeMessageInfo(msg.info) as MessageV2.Info
|
||||
const { id, sessionID: _, ...msgData } = msgInfo
|
||||
Database.use((db) =>
|
||||
db
|
||||
.insert(MessageTable)
|
||||
.values({
|
||||
id,
|
||||
session_id: row.id,
|
||||
time_created: msgInfo.time?.created ?? Date.now(),
|
||||
data: msgData,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.run(),
|
||||
)
|
||||
|
||||
for (const part of msg.parts) {
|
||||
const partInfo = decodePart(part) as MessageV2.Part
|
||||
const { id: partId, sessionID: _s, messageID, ...partData } = partInfo
|
||||
Database.use((db) =>
|
||||
db
|
||||
.insert(PartTable)
|
||||
.values({
|
||||
id: partId,
|
||||
message_id: messageID,
|
||||
session_id: row.id,
|
||||
data: partData,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.run(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write(`Imported session: ${exportData.info.id}`)
|
||||
process.stdout.write(EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,16 @@
|
||||
import { intro, log, outro, spinner } from "@clack/prompts"
|
||||
import { Effect } from "effect"
|
||||
import type { Argv } from "yargs"
|
||||
|
||||
import { ConfigPaths } from "@/config/paths"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { installPlugin, patchPluginConfig, readPluginManifest } from "../../plugin/install"
|
||||
import { resolvePluginTarget } from "../../plugin/shared"
|
||||
import { Instance } from "../../project/instance"
|
||||
import { errorMessage } from "../../util/error"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Process } from "@/util/process"
|
||||
import { UI } from "../ui"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { cmd } from "./cmd"
|
||||
|
||||
type Spin = {
|
||||
start: (msg: string) => void
|
||||
@@ -175,12 +175,12 @@ export function createPlugTask(input: PlugInput, dep: PlugDeps = defaultPlugDeps
|
||||
}
|
||||
}
|
||||
|
||||
export const PluginCommand = effectCmd({
|
||||
export const PluginCommand = cmd({
|
||||
command: "plugin <module>",
|
||||
aliases: ["plug"],
|
||||
describe: "install plugin and update config",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
builder: (yargs: Argv) => {
|
||||
return yargs
|
||||
.positional("module", {
|
||||
type: "string",
|
||||
describe: "npm module name",
|
||||
@@ -196,8 +196,9 @@ export const PluginCommand = effectCmd({
|
||||
type: "boolean",
|
||||
default: false,
|
||||
describe: "replace existing plugin version",
|
||||
}),
|
||||
handler: Effect.fn("Cli.plug")(function* (args) {
|
||||
})
|
||||
},
|
||||
handler: async (args) => {
|
||||
const mod = String(args.module ?? "").trim()
|
||||
if (!mod) {
|
||||
UI.error("module is required")
|
||||
@@ -213,18 +214,20 @@ export const PluginCommand = effectCmd({
|
||||
global: Boolean(args.global),
|
||||
force: Boolean(args.force),
|
||||
})
|
||||
let ok = true
|
||||
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return
|
||||
const ok = yield* Effect.promise(() =>
|
||||
run({
|
||||
vcs: ctx.project.vcs,
|
||||
worktree: ctx.worktree,
|
||||
directory: ctx.directory,
|
||||
}),
|
||||
)
|
||||
await Instance.provide({
|
||||
directory: process.cwd(),
|
||||
fn: async () => {
|
||||
ok = await run({
|
||||
vcs: Instance.project.vcs,
|
||||
worktree: Instance.worktree,
|
||||
directory: Instance.directory,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
outro("Done")
|
||||
if (!ok) process.exitCode = 1
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Effect } from "effect"
|
||||
import { UI } from "../ui"
|
||||
import { effectCmd, fail } from "../effect-cmd"
|
||||
import { cmd } from "./cmd"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Git } from "@/git"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { Process } from "@/util/process"
|
||||
|
||||
export const PrCommand = effectCmd({
|
||||
export const PrCommand = cmd({
|
||||
command: "pr <number>",
|
||||
describe: "fetch and checkout a GitHub PR branch, then run opencode",
|
||||
builder: (yargs) =>
|
||||
@@ -14,102 +14,125 @@ export const PrCommand = effectCmd({
|
||||
describe: "PR number to checkout",
|
||||
demandOption: true,
|
||||
}),
|
||||
handler: Effect.fn("Cli.pr")(function* (args) {
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return yield* fail("Could not load instance context")
|
||||
if (ctx.project.vcs !== "git") {
|
||||
return yield* fail("Could not find git repository. Please run this command from a git repository.")
|
||||
}
|
||||
|
||||
const git = yield* Git.Service
|
||||
const worktree = ctx.worktree
|
||||
|
||||
const prNumber = args.number
|
||||
const localBranchName = `pr/${prNumber}`
|
||||
UI.println(`Fetching and checking out PR #${prNumber}...`)
|
||||
|
||||
const checkout = yield* Effect.promise(() =>
|
||||
Process.run(["gh", "pr", "checkout", `${prNumber}`, "--branch", localBranchName, "--force"], { nothrow: true }),
|
||||
)
|
||||
if (checkout.code !== 0) {
|
||||
return yield* fail(`Failed to checkout PR #${prNumber}. Make sure you have gh CLI installed and authenticated.`)
|
||||
}
|
||||
|
||||
const prInfoResult = yield* Effect.promise(() =>
|
||||
Process.text(
|
||||
[
|
||||
"gh",
|
||||
"pr",
|
||||
"view",
|
||||
`${prNumber}`,
|
||||
"--json",
|
||||
"headRepository,headRepositoryOwner,isCrossRepository,headRefName,body",
|
||||
],
|
||||
{ nothrow: true },
|
||||
),
|
||||
)
|
||||
|
||||
let sessionId: string | undefined
|
||||
|
||||
if (prInfoResult.code === 0 && prInfoResult.text.trim()) {
|
||||
const prInfo = JSON.parse(prInfoResult.text)
|
||||
|
||||
if (prInfo?.isCrossRepository && prInfo.headRepository && prInfo.headRepositoryOwner) {
|
||||
const forkOwner = prInfo.headRepositoryOwner.login
|
||||
const forkName = prInfo.headRepository.name
|
||||
const remoteName = forkOwner
|
||||
|
||||
const remotes = (yield* git.run(["remote"], { cwd: worktree })).text().trim()
|
||||
if (!remotes.split("\n").includes(remoteName)) {
|
||||
yield* git.run(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], {
|
||||
cwd: worktree,
|
||||
})
|
||||
UI.println(`Added fork remote: ${remoteName}`)
|
||||
async handler(args) {
|
||||
await Instance.provide({
|
||||
directory: process.cwd(),
|
||||
async fn() {
|
||||
const project = Instance.project
|
||||
if (project.vcs !== "git") {
|
||||
UI.error("Could not find git repository. Please run this command from a git repository.")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
yield* git.run(["branch", `--set-upstream-to=${remoteName}/${prInfo.headRefName}`, localBranchName], {
|
||||
cwd: worktree,
|
||||
})
|
||||
}
|
||||
const prNumber = args.number
|
||||
const localBranchName = `pr/${prNumber}`
|
||||
UI.println(`Fetching and checking out PR #${prNumber}...`)
|
||||
|
||||
if (prInfo?.body) {
|
||||
const sessionMatch = prInfo.body.match(/https:\/\/opncd\.ai\/s\/([a-zA-Z0-9_-]+)/)
|
||||
if (sessionMatch) {
|
||||
const sessionUrl = sessionMatch[0]
|
||||
UI.println(`Found opencode session: ${sessionUrl}`)
|
||||
UI.println(`Importing session...`)
|
||||
// Use gh pr checkout with custom branch name
|
||||
const result = await Process.run(
|
||||
["gh", "pr", "checkout", `${prNumber}`, "--branch", localBranchName, "--force"],
|
||||
{
|
||||
nothrow: true,
|
||||
},
|
||||
)
|
||||
|
||||
const importResult = yield* Effect.promise(() =>
|
||||
Process.text(["opencode", "import", sessionUrl], { nothrow: true }),
|
||||
)
|
||||
if (importResult.code === 0) {
|
||||
const sessionIdMatch = importResult.text.trim().match(/Imported session: ([a-zA-Z0-9_-]+)/)
|
||||
if (sessionIdMatch) {
|
||||
sessionId = sessionIdMatch[1]
|
||||
UI.println(`Session imported: ${sessionId}`)
|
||||
if (result.code !== 0) {
|
||||
UI.error(`Failed to checkout PR #${prNumber}. Make sure you have gh CLI installed and authenticated.`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Fetch PR info for fork handling and session link detection
|
||||
const prInfoResult = await Process.text(
|
||||
[
|
||||
"gh",
|
||||
"pr",
|
||||
"view",
|
||||
`${prNumber}`,
|
||||
"--json",
|
||||
"headRepository,headRepositoryOwner,isCrossRepository,headRefName,body",
|
||||
],
|
||||
{ nothrow: true },
|
||||
)
|
||||
|
||||
let sessionId: string | undefined
|
||||
|
||||
if (prInfoResult.code === 0) {
|
||||
const prInfoText = prInfoResult.text
|
||||
if (prInfoText.trim()) {
|
||||
const prInfo = JSON.parse(prInfoText)
|
||||
|
||||
// Handle fork PRs
|
||||
if (prInfo && prInfo.isCrossRepository && prInfo.headRepository && prInfo.headRepositoryOwner) {
|
||||
const forkOwner = prInfo.headRepositoryOwner.login
|
||||
const forkName = prInfo.headRepository.name
|
||||
const remoteName = forkOwner
|
||||
|
||||
// Check if remote already exists
|
||||
const remotes = await AppRuntime.runPromise(
|
||||
Git.Service.use((git) => git.run(["remote"], { cwd: Instance.worktree })),
|
||||
).then((x) => x.text().trim())
|
||||
if (!remotes.split("\n").includes(remoteName)) {
|
||||
await AppRuntime.runPromise(
|
||||
Git.Service.use((git) =>
|
||||
git.run(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], {
|
||||
cwd: Instance.worktree,
|
||||
}),
|
||||
),
|
||||
)
|
||||
UI.println(`Added fork remote: ${remoteName}`)
|
||||
}
|
||||
|
||||
// Set upstream to the fork so pushes go there
|
||||
const headRefName = prInfo.headRefName
|
||||
await AppRuntime.runPromise(
|
||||
Git.Service.use((git) =>
|
||||
git.run(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], {
|
||||
cwd: Instance.worktree,
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Check for opencode session link in PR body
|
||||
if (prInfo && prInfo.body) {
|
||||
const sessionMatch = prInfo.body.match(/https:\/\/opncd\.ai\/s\/([a-zA-Z0-9_-]+)/)
|
||||
if (sessionMatch) {
|
||||
const sessionUrl = sessionMatch[0]
|
||||
UI.println(`Found opencode session: ${sessionUrl}`)
|
||||
UI.println(`Importing session...`)
|
||||
|
||||
const importResult = await Process.text(["opencode", "import", sessionUrl], {
|
||||
nothrow: true,
|
||||
})
|
||||
if (importResult.code === 0) {
|
||||
const importOutput = importResult.text.trim()
|
||||
// Extract session ID from the output (format: "Imported session: <session-id>")
|
||||
const sessionIdMatch = importOutput.match(/Imported session: ([a-zA-Z0-9_-]+)/)
|
||||
if (sessionIdMatch) {
|
||||
sessionId = sessionIdMatch[1]
|
||||
UI.println(`Session imported: ${sessionId}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
UI.println(`Successfully checked out PR #${prNumber} as branch '${localBranchName}'`)
|
||||
UI.println()
|
||||
UI.println("Starting opencode...")
|
||||
UI.println()
|
||||
UI.println(`Successfully checked out PR #${prNumber} as branch '${localBranchName}'`)
|
||||
UI.println()
|
||||
UI.println("Starting opencode...")
|
||||
UI.println()
|
||||
|
||||
const opencodeArgs = sessionId ? ["-s", sessionId] : []
|
||||
const code = yield* Effect.promise(
|
||||
() =>
|
||||
Process.spawn(["opencode", ...opencodeArgs], {
|
||||
const opencodeArgs = sessionId ? ["-s", sessionId] : []
|
||||
const opencodeProcess = Process.spawn(["opencode", ...opencodeArgs], {
|
||||
stdin: "inherit",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
cwd: process.cwd(),
|
||||
}).exited,
|
||||
)
|
||||
// Match legacy throw semantics — propagate as a defect so the top-level
|
||||
// index.ts catch handles it identically (exit 1, "Unexpected error" banner).
|
||||
if (code !== 0) return yield* Effect.die(new Error(`opencode exited with code ${code}`))
|
||||
}),
|
||||
})
|
||||
const code = await opencodeProcess.exited
|
||||
if (code !== 0) throw new Error(`opencode exited with code ${code}`)
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Auth } from "../../auth"
|
||||
import { AppRuntime } from "../../effect/app-runtime"
|
||||
import { cmd } from "./cmd"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
import * as prompts from "@clack/prompts"
|
||||
import { UI } from "../ui"
|
||||
import { ModelsDev } from "@/provider/models"
|
||||
@@ -14,6 +13,7 @@ import os from "os"
|
||||
import { Config } from "@/config/config"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Plugin } from "../../plugin"
|
||||
import { Instance } from "../../project/instance"
|
||||
import type { Hooks } from "@opencode-ai/plugin"
|
||||
import { Process } from "@/util/process"
|
||||
import { text } from "node:stream/consumers"
|
||||
@@ -232,59 +232,58 @@ export const ProvidersCommand = cmd({
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
export const ProvidersListCommand = effectCmd({
|
||||
export const ProvidersListCommand = cmd({
|
||||
command: "list",
|
||||
aliases: ["ls"],
|
||||
describe: "list providers and credentials",
|
||||
// Lists global credentials + provider env vars; no project instance needed.
|
||||
instance: false,
|
||||
handler: Effect.fn("Cli.providers.list")(function* (_args) {
|
||||
const authSvc = yield* Auth.Service
|
||||
const modelsDev = yield* ModelsDev.Service
|
||||
yield* Effect.promise(async () => {
|
||||
async handler(_args) {
|
||||
UI.empty()
|
||||
const authPath = path.join(Global.Path.data, "auth.json")
|
||||
const homedir = os.homedir()
|
||||
const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath
|
||||
prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
|
||||
const results = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
return Object.entries(yield* auth.all())
|
||||
}),
|
||||
)
|
||||
const database = await getModels()
|
||||
|
||||
for (const [providerID, result] of results) {
|
||||
const name = database[providerID]?.name || providerID
|
||||
prompts.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`)
|
||||
}
|
||||
|
||||
prompts.outro(`${results.length} credentials`)
|
||||
|
||||
const activeEnvVars: Array<{ provider: string; envVar: string }> = []
|
||||
|
||||
for (const [providerID, provider] of Object.entries(database)) {
|
||||
for (const envVar of provider.env) {
|
||||
if (process.env[envVar]) {
|
||||
activeEnvVars.push({
|
||||
provider: provider.name || providerID,
|
||||
envVar,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (activeEnvVars.length > 0) {
|
||||
UI.empty()
|
||||
const authPath = path.join(Global.Path.data, "auth.json")
|
||||
const homedir = os.homedir()
|
||||
const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath
|
||||
prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
|
||||
const results = Object.entries(await Effect.runPromise(authSvc.all()))
|
||||
const database = await Effect.runPromise(modelsDev.get())
|
||||
prompts.intro("Environment")
|
||||
|
||||
for (const [providerID, result] of results) {
|
||||
const name = database[providerID]?.name || providerID
|
||||
prompts.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`)
|
||||
for (const { provider, envVar } of activeEnvVars) {
|
||||
prompts.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`)
|
||||
}
|
||||
|
||||
prompts.outro(`${results.length} credentials`)
|
||||
|
||||
const activeEnvVars: Array<{ provider: string; envVar: string }> = []
|
||||
|
||||
for (const [providerID, provider] of Object.entries(database)) {
|
||||
for (const envVar of provider.env) {
|
||||
if (process.env[envVar]) {
|
||||
activeEnvVars.push({
|
||||
provider: provider.name || providerID,
|
||||
envVar,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (activeEnvVars.length > 0) {
|
||||
UI.empty()
|
||||
prompts.intro("Environment")
|
||||
|
||||
for (const { provider, envVar } of activeEnvVars) {
|
||||
prompts.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`)
|
||||
}
|
||||
|
||||
prompts.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s"))
|
||||
}
|
||||
})
|
||||
}),
|
||||
prompts.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s"))
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const ProvidersLoginCommand = effectCmd({
|
||||
export const ProvidersLoginCommand = cmd({
|
||||
command: "login [url]",
|
||||
describe: "log in to a provider",
|
||||
builder: (yargs) =>
|
||||
@@ -303,219 +302,228 @@ export const ProvidersLoginCommand = effectCmd({
|
||||
describe: "login method label (skips method selection)",
|
||||
type: "string",
|
||||
}),
|
||||
handler: Effect.fn("Cli.providers.login")(function* (args) {
|
||||
const cfgSvc = yield* Config.Service
|
||||
const pluginSvc = yield* Plugin.Service
|
||||
yield* Effect.promise(async () => {
|
||||
UI.empty()
|
||||
prompts.intro("Add credential")
|
||||
if (args.url) {
|
||||
const url = args.url.replace(/\/+$/, "")
|
||||
const wellknown = (await fetch(`${url}/.well-known/opencode`).then((x) => x.json())) as {
|
||||
auth: { command: string[]; env: string }
|
||||
}
|
||||
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
|
||||
const proc = Process.spawn(wellknown.auth.command, {
|
||||
stdout: "pipe",
|
||||
stderr: "inherit",
|
||||
})
|
||||
if (!proc.stdout) {
|
||||
prompts.log.error("Failed")
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
const [exit, token] = await Promise.all([proc.exited, text(proc.stdout)])
|
||||
if (exit !== 0) {
|
||||
prompts.log.error("Failed")
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
await put(url, {
|
||||
type: "wellknown",
|
||||
key: wellknown.auth.env,
|
||||
token: token.trim(),
|
||||
})
|
||||
prompts.log.success("Logged into " + url)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
await refreshModels().catch(() => {})
|
||||
|
||||
const config = await Effect.runPromise(cfgSvc.get())
|
||||
|
||||
const disabled = new Set(config.disabled_providers ?? [])
|
||||
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
|
||||
|
||||
const providers = await getModels().then((x) => {
|
||||
const filtered: Record<string, (typeof x)[string]> = {}
|
||||
for (const [key, value] of Object.entries(x)) {
|
||||
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
|
||||
filtered[key] = value
|
||||
async handler(args) {
|
||||
await Instance.provide({
|
||||
directory: process.cwd(),
|
||||
async fn() {
|
||||
UI.empty()
|
||||
prompts.intro("Add credential")
|
||||
if (args.url) {
|
||||
const url = args.url.replace(/\/+$/, "")
|
||||
const wellknown = (await fetch(`${url}/.well-known/opencode`).then((x) => x.json())) as {
|
||||
auth: { command: string[]; env: string }
|
||||
}
|
||||
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
|
||||
const proc = Process.spawn(wellknown.auth.command, {
|
||||
stdout: "pipe",
|
||||
})
|
||||
if (!proc.stdout) {
|
||||
prompts.log.error("Failed")
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
const [exit, token] = await Promise.all([proc.exited, text(proc.stdout)])
|
||||
if (exit !== 0) {
|
||||
prompts.log.error("Failed")
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
await put(url, {
|
||||
type: "wellknown",
|
||||
key: wellknown.auth.env,
|
||||
token: token.trim(),
|
||||
})
|
||||
prompts.log.success("Logged into " + url)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
return filtered
|
||||
})
|
||||
const hooks = await Effect.runPromise(pluginSvc.list())
|
||||
await refreshModels().catch(() => {})
|
||||
|
||||
const priority: Record<string, number> = {
|
||||
opencode: 0,
|
||||
openai: 1,
|
||||
"github-copilot": 2,
|
||||
google: 3,
|
||||
anthropic: 4,
|
||||
openrouter: 5,
|
||||
vercel: 6,
|
||||
}
|
||||
const pluginProviders = resolvePluginProviders({
|
||||
hooks,
|
||||
existingProviders: providers,
|
||||
disabled,
|
||||
enabled,
|
||||
providerNames: Object.fromEntries(Object.entries(config.provider ?? {}).map(([id, p]) => [id, p.name])),
|
||||
})
|
||||
const options = [
|
||||
...pipe(
|
||||
providers,
|
||||
values(),
|
||||
sortBy(
|
||||
(x) => priority[x.id] ?? 99,
|
||||
(x) => x.name ?? x.id,
|
||||
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))
|
||||
|
||||
const disabled = new Set(config.disabled_providers ?? [])
|
||||
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
|
||||
|
||||
const providers = await getModels().then((x) => {
|
||||
const filtered: Record<string, (typeof x)[string]> = {}
|
||||
for (const [key, value] of Object.entries(x)) {
|
||||
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
|
||||
filtered[key] = value
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
})
|
||||
const hooks = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
return yield* plugin.list()
|
||||
}),
|
||||
)
|
||||
|
||||
const priority: Record<string, number> = {
|
||||
opencode: 0,
|
||||
openai: 1,
|
||||
"github-copilot": 2,
|
||||
google: 3,
|
||||
anthropic: 4,
|
||||
openrouter: 5,
|
||||
vercel: 6,
|
||||
}
|
||||
const pluginProviders = resolvePluginProviders({
|
||||
hooks,
|
||||
existingProviders: providers,
|
||||
disabled,
|
||||
enabled,
|
||||
providerNames: Object.fromEntries(Object.entries(config.provider ?? {}).map(([id, p]) => [id, p.name])),
|
||||
})
|
||||
const options = [
|
||||
...pipe(
|
||||
providers,
|
||||
values(),
|
||||
sortBy(
|
||||
(x) => priority[x.id] ?? 99,
|
||||
(x) => x.name ?? x.id,
|
||||
),
|
||||
map((x) => ({
|
||||
label: x.name,
|
||||
value: x.id,
|
||||
hint: {
|
||||
opencode: "recommended",
|
||||
openai: "ChatGPT Plus/Pro or API key",
|
||||
}[x.id],
|
||||
})),
|
||||
),
|
||||
map((x) => ({
|
||||
...pluginProviders.map((x) => ({
|
||||
label: x.name,
|
||||
value: x.id,
|
||||
hint: {
|
||||
opencode: "recommended",
|
||||
openai: "ChatGPT Plus/Pro or API key",
|
||||
}[x.id],
|
||||
hint: "plugin",
|
||||
})),
|
||||
),
|
||||
...pluginProviders.map((x) => ({
|
||||
label: x.name,
|
||||
value: x.id,
|
||||
hint: "plugin",
|
||||
})),
|
||||
]
|
||||
]
|
||||
|
||||
let provider: string
|
||||
if (args.provider) {
|
||||
const input = args.provider
|
||||
const byID = options.find((x) => x.value === input)
|
||||
const byName = options.find((x) => x.label.toLowerCase() === input.toLowerCase())
|
||||
const match = byID ?? byName
|
||||
if (!match) {
|
||||
prompts.log.error(`Unknown provider "${input}"`)
|
||||
process.exit(1)
|
||||
let provider: string
|
||||
if (args.provider) {
|
||||
const input = args.provider
|
||||
const byID = options.find((x) => x.value === input)
|
||||
const byName = options.find((x) => x.label.toLowerCase() === input.toLowerCase())
|
||||
const match = byID ?? byName
|
||||
if (!match) {
|
||||
prompts.log.error(`Unknown provider "${input}"`)
|
||||
process.exit(1)
|
||||
}
|
||||
provider = match.value
|
||||
} else {
|
||||
const selected = await prompts.autocomplete({
|
||||
message: "Select provider",
|
||||
maxItems: 8,
|
||||
options: [
|
||||
...options,
|
||||
{
|
||||
value: "other",
|
||||
label: "Other",
|
||||
},
|
||||
],
|
||||
})
|
||||
if (prompts.isCancel(selected)) throw new UI.CancelledError()
|
||||
provider = selected as string
|
||||
}
|
||||
provider = match.value
|
||||
} else {
|
||||
const selected = await prompts.autocomplete({
|
||||
message: "Select provider",
|
||||
maxItems: 8,
|
||||
options: [
|
||||
...options,
|
||||
{
|
||||
value: "other",
|
||||
label: "Other",
|
||||
},
|
||||
],
|
||||
})
|
||||
if (prompts.isCancel(selected)) throw new UI.CancelledError()
|
||||
provider = selected as string
|
||||
}
|
||||
|
||||
const plugin = hooks.findLast((x) => x.auth?.provider === provider)
|
||||
if (plugin && plugin.auth) {
|
||||
const handled = await handlePluginAuth({ auth: plugin.auth }, provider, args.method)
|
||||
if (handled) return
|
||||
}
|
||||
|
||||
if (provider === "other") {
|
||||
const custom = await prompts.text({
|
||||
message: "Enter provider id",
|
||||
validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"),
|
||||
})
|
||||
if (prompts.isCancel(custom)) throw new UI.CancelledError()
|
||||
provider = custom.replace(/^@ai-sdk\//, "")
|
||||
|
||||
const customPlugin = hooks.findLast((x) => x.auth?.provider === provider)
|
||||
if (customPlugin && customPlugin.auth) {
|
||||
const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider, args.method)
|
||||
const plugin = hooks.findLast((x) => x.auth?.provider === provider)
|
||||
if (plugin && plugin.auth) {
|
||||
const handled = await handlePluginAuth({ auth: plugin.auth }, provider, args.method)
|
||||
if (handled) return
|
||||
}
|
||||
|
||||
prompts.log.warn(
|
||||
`This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`,
|
||||
)
|
||||
}
|
||||
if (provider === "other") {
|
||||
const custom = await prompts.text({
|
||||
message: "Enter provider id",
|
||||
validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"),
|
||||
})
|
||||
if (prompts.isCancel(custom)) throw new UI.CancelledError()
|
||||
provider = custom.replace(/^@ai-sdk\//, "")
|
||||
|
||||
if (provider === "amazon-bedrock") {
|
||||
prompts.log.info(
|
||||
"Amazon Bedrock authentication priority:\n" +
|
||||
" 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" +
|
||||
" 2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" +
|
||||
"Configure via opencode.json options (profile, region, endpoint) or\n" +
|
||||
"AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_WEB_IDENTITY_TOKEN_FILE).",
|
||||
)
|
||||
}
|
||||
const customPlugin = hooks.findLast((x) => x.auth?.provider === provider)
|
||||
if (customPlugin && customPlugin.auth) {
|
||||
const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider, args.method)
|
||||
if (handled) return
|
||||
}
|
||||
|
||||
if (provider === "opencode") {
|
||||
prompts.log.info("Create an api key at https://opencode.ai/auth")
|
||||
}
|
||||
prompts.log.warn(
|
||||
`This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`,
|
||||
)
|
||||
}
|
||||
|
||||
if (provider === "vercel") {
|
||||
prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token")
|
||||
}
|
||||
if (provider === "amazon-bedrock") {
|
||||
prompts.log.info(
|
||||
"Amazon Bedrock authentication priority:\n" +
|
||||
" 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" +
|
||||
" 2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" +
|
||||
"Configure via opencode.json options (profile, region, endpoint) or\n" +
|
||||
"AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_WEB_IDENTITY_TOKEN_FILE).",
|
||||
)
|
||||
}
|
||||
|
||||
if (["cloudflare", "cloudflare-ai-gateway"].includes(provider)) {
|
||||
prompts.log.info(
|
||||
"Cloudflare AI Gateway can be configured with CLOUDFLARE_GATEWAY_ID, CLOUDFLARE_ACCOUNT_ID, and CLOUDFLARE_API_TOKEN environment variables. Read more: https://opencode.ai/docs/providers/#cloudflare-ai-gateway",
|
||||
)
|
||||
}
|
||||
if (provider === "opencode") {
|
||||
prompts.log.info("Create an api key at https://opencode.ai/auth")
|
||||
}
|
||||
|
||||
const key = await prompts.password({
|
||||
message: "Enter your API key",
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(key)) throw new UI.CancelledError()
|
||||
await put(provider, {
|
||||
type: "api",
|
||||
key,
|
||||
})
|
||||
if (provider === "vercel") {
|
||||
prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token")
|
||||
}
|
||||
|
||||
prompts.outro("Done")
|
||||
if (["cloudflare", "cloudflare-ai-gateway"].includes(provider)) {
|
||||
prompts.log.info(
|
||||
"Cloudflare AI Gateway can be configured with CLOUDFLARE_GATEWAY_ID, CLOUDFLARE_ACCOUNT_ID, and CLOUDFLARE_API_TOKEN environment variables. Read more: https://opencode.ai/docs/providers/#cloudflare-ai-gateway",
|
||||
)
|
||||
}
|
||||
|
||||
const key = await prompts.password({
|
||||
message: "Enter your API key",
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(key)) throw new UI.CancelledError()
|
||||
await put(provider, {
|
||||
type: "api",
|
||||
key,
|
||||
})
|
||||
|
||||
prompts.outro("Done")
|
||||
},
|
||||
})
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
export const ProvidersLogoutCommand = effectCmd({
|
||||
export const ProvidersLogoutCommand = cmd({
|
||||
command: "logout",
|
||||
describe: "log out from a configured provider",
|
||||
// Removes a global auth credential; no project instance needed.
|
||||
instance: false,
|
||||
handler: Effect.fn("Cli.providers.logout")(function* (_args) {
|
||||
const authSvc = yield* Auth.Service
|
||||
const modelsDev = yield* ModelsDev.Service
|
||||
yield* Effect.promise(async () => {
|
||||
UI.empty()
|
||||
const credentials: Array<[string, Auth.Info]> = Object.entries(await Effect.runPromise(authSvc.all()))
|
||||
prompts.intro("Remove credential")
|
||||
if (credentials.length === 0) {
|
||||
prompts.log.error("No credentials found")
|
||||
return
|
||||
}
|
||||
const database = await Effect.runPromise(modelsDev.get())
|
||||
const selected = await prompts.select({
|
||||
message: "Select provider",
|
||||
options: credentials.map(([key, value]) => ({
|
||||
label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
|
||||
value: key,
|
||||
})),
|
||||
})
|
||||
if (prompts.isCancel(selected)) throw new UI.CancelledError()
|
||||
const providerID = selected as string
|
||||
await Effect.runPromise(authSvc.remove(providerID))
|
||||
prompts.outro("Logout successful")
|
||||
async handler(_args) {
|
||||
UI.empty()
|
||||
const credentials: Array<[string, Auth.Info]> = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
return Object.entries(yield* auth.all())
|
||||
}),
|
||||
)
|
||||
prompts.intro("Remove credential")
|
||||
if (credentials.length === 0) {
|
||||
prompts.log.error("No credentials found")
|
||||
return
|
||||
}
|
||||
const database = await getModels()
|
||||
const selected = await prompts.select({
|
||||
message: "Select provider",
|
||||
options: credentials.map(([key, value]) => ({
|
||||
label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
|
||||
value: key,
|
||||
})),
|
||||
})
|
||||
}),
|
||||
if (prompts.isCancel(selected)) throw new UI.CancelledError()
|
||||
const providerID = selected as string
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
yield* auth.remove(providerID)
|
||||
}),
|
||||
)
|
||||
prompts.outro("Logout successful")
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { Argv } from "yargs"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Effect } from "effect"
|
||||
import { UI } from "../ui"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
import { cmd } from "./cmd"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { ServerAuth } from "@/server/auth"
|
||||
import { bootstrap } from "../bootstrap"
|
||||
import { EOL } from "os"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
|
||||
@@ -23,8 +22,7 @@ import { WriteTool } from "../../tool/write"
|
||||
import { WebSearchTool } from "../../tool/websearch"
|
||||
import { TaskTool } from "../../tool/task"
|
||||
import { SkillTool } from "../../tool/skill"
|
||||
import { ShellTool } from "../../tool/shell"
|
||||
import { ShellID } from "../../tool/shell/id"
|
||||
import { BashTool } from "../../tool/bash"
|
||||
import { TodoWriteTool } from "../../tool/todo"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
@@ -177,7 +175,7 @@ function skill(info: ToolProps<typeof SkillTool>) {
|
||||
})
|
||||
}
|
||||
|
||||
function shell(info: ToolProps<typeof ShellTool>) {
|
||||
function bash(info: ToolProps<typeof BashTool>) {
|
||||
const output = info.part.state.status === "completed" ? info.part.state.output?.trim() : undefined
|
||||
block(
|
||||
{
|
||||
@@ -204,17 +202,11 @@ function normalizePath(input?: string) {
|
||||
return input
|
||||
}
|
||||
|
||||
export const RunCommand = effectCmd({
|
||||
export const RunCommand = cmd({
|
||||
command: "run [message..]",
|
||||
describe: "run opencode with a message",
|
||||
// --attach connects to a remote server (no local instance needed); the
|
||||
// default path runs an in-process server and needs the project instance.
|
||||
instance: (args) => !args.attach,
|
||||
// For --dir without --attach, load instance for the resolved target dir.
|
||||
// The handler also chdirs (preserving the legacy order: chdir → file resolution).
|
||||
directory: (args) => (args.dir && !args.attach ? path.resolve(process.cwd(), args.dir) : process.cwd()),
|
||||
builder: (yargs: Argv) =>
|
||||
yargs
|
||||
builder: (yargs: Argv) => {
|
||||
return yargs
|
||||
.positional("message", {
|
||||
describe: "message to send",
|
||||
type: "string",
|
||||
@@ -298,313 +290,291 @@ export const RunCommand = effectCmd({
|
||||
type: "boolean",
|
||||
describe: "auto-approve permissions that are not explicitly denied (dangerous!)",
|
||||
default: false,
|
||||
}),
|
||||
handler: Effect.fn("Cli.run")(function* (args) {
|
||||
yield* Effect.promise(async () => {
|
||||
let message = [...args.message, ...(args["--"] || [])]
|
||||
.map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg))
|
||||
.join(" ")
|
||||
})
|
||||
},
|
||||
handler: async (args) => {
|
||||
let message = [...args.message, ...(args["--"] || [])]
|
||||
.map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg))
|
||||
.join(" ")
|
||||
|
||||
const directory = (() => {
|
||||
if (!args.dir) return undefined
|
||||
if (args.attach) return args.dir
|
||||
try {
|
||||
process.chdir(args.dir)
|
||||
return process.cwd()
|
||||
} catch {
|
||||
UI.error("Failed to change directory to " + args.dir)
|
||||
const directory = (() => {
|
||||
if (!args.dir) return undefined
|
||||
if (args.attach) return args.dir
|
||||
try {
|
||||
process.chdir(args.dir)
|
||||
return process.cwd()
|
||||
} catch {
|
||||
UI.error("Failed to change directory to " + args.dir)
|
||||
process.exit(1)
|
||||
}
|
||||
})()
|
||||
|
||||
const files: { type: "file"; url: string; filename: string; mime: string }[] = []
|
||||
if (args.file) {
|
||||
const list = Array.isArray(args.file) ? args.file : [args.file]
|
||||
|
||||
for (const filePath of list) {
|
||||
const resolvedPath = path.resolve(process.cwd(), filePath)
|
||||
if (!(await Filesystem.exists(resolvedPath))) {
|
||||
UI.error(`File not found: ${filePath}`)
|
||||
process.exit(1)
|
||||
}
|
||||
})()
|
||||
|
||||
const files: { type: "file"; url: string; filename: string; mime: string }[] = []
|
||||
if (args.file) {
|
||||
const list = Array.isArray(args.file) ? args.file : [args.file]
|
||||
const mime = (await Filesystem.isDir(resolvedPath)) ? "application/x-directory" : "text/plain"
|
||||
|
||||
for (const filePath of list) {
|
||||
const resolvedPath = path.resolve(process.cwd(), filePath)
|
||||
if (!(await Filesystem.exists(resolvedPath))) {
|
||||
UI.error(`File not found: ${filePath}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const mime = (await Filesystem.isDir(resolvedPath)) ? "application/x-directory" : "text/plain"
|
||||
|
||||
files.push({
|
||||
type: "file",
|
||||
url: pathToFileURL(resolvedPath).href,
|
||||
filename: path.basename(resolvedPath),
|
||||
mime,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!process.stdin.isTTY) message += "\n" + (await Bun.stdin.text())
|
||||
|
||||
if (message.trim().length === 0 && !args.command) {
|
||||
UI.error("You must provide a message or a command")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (args.fork && !args.continue && !args.session) {
|
||||
UI.error("--fork requires --continue or --session")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const rules: Permission.Ruleset = [
|
||||
{
|
||||
permission: "question",
|
||||
action: "deny",
|
||||
pattern: "*",
|
||||
},
|
||||
{
|
||||
permission: "plan_enter",
|
||||
action: "deny",
|
||||
pattern: "*",
|
||||
},
|
||||
{
|
||||
permission: "plan_exit",
|
||||
action: "deny",
|
||||
pattern: "*",
|
||||
},
|
||||
]
|
||||
|
||||
function title() {
|
||||
if (args.title === undefined) return
|
||||
if (args.title !== "") return args.title
|
||||
return message.slice(0, 50) + (message.length > 50 ? "..." : "")
|
||||
}
|
||||
|
||||
async function session(sdk: OpencodeClient) {
|
||||
const baseID = args.continue ? (await sdk.session.list()).data?.find((s) => !s.parentID)?.id : args.session
|
||||
|
||||
if (baseID && args.fork) {
|
||||
const forked = await sdk.session.fork({ sessionID: baseID })
|
||||
return forked.data?.id
|
||||
}
|
||||
|
||||
if (baseID) return baseID
|
||||
|
||||
const name = title()
|
||||
const result = await sdk.session.create({ title: name, permission: rules })
|
||||
return result.data?.id
|
||||
}
|
||||
|
||||
async function share(sdk: OpencodeClient, sessionID: string) {
|
||||
const cfg = await sdk.config.get()
|
||||
if (!cfg.data) return
|
||||
if (cfg.data.share !== "auto" && !Flag.OPENCODE_AUTO_SHARE && !args.share) return
|
||||
const res = await sdk.session.share({ sessionID }).catch((error) => {
|
||||
if (error instanceof Error && error.message.includes("disabled")) {
|
||||
UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message)
|
||||
}
|
||||
return { error }
|
||||
files.push({
|
||||
type: "file",
|
||||
url: pathToFileURL(resolvedPath).href,
|
||||
filename: path.basename(resolvedPath),
|
||||
mime,
|
||||
})
|
||||
if (!res.error && "data" in res && res.data?.share?.url) {
|
||||
UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + res.data.share.url)
|
||||
}
|
||||
}
|
||||
|
||||
if (!process.stdin.isTTY) message += "\n" + (await Bun.stdin.text())
|
||||
|
||||
if (message.trim().length === 0 && !args.command) {
|
||||
UI.error("You must provide a message or a command")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (args.fork && !args.continue && !args.session) {
|
||||
UI.error("--fork requires --continue or --session")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const rules: Permission.Ruleset = [
|
||||
{
|
||||
permission: "question",
|
||||
action: "deny",
|
||||
pattern: "*",
|
||||
},
|
||||
{
|
||||
permission: "plan_enter",
|
||||
action: "deny",
|
||||
pattern: "*",
|
||||
},
|
||||
{
|
||||
permission: "plan_exit",
|
||||
action: "deny",
|
||||
pattern: "*",
|
||||
},
|
||||
]
|
||||
|
||||
function title() {
|
||||
if (args.title === undefined) return
|
||||
if (args.title !== "") return args.title
|
||||
return message.slice(0, 50) + (message.length > 50 ? "..." : "")
|
||||
}
|
||||
|
||||
async function session(sdk: OpencodeClient) {
|
||||
const baseID = args.continue ? (await sdk.session.list()).data?.find((s) => !s.parentID)?.id : args.session
|
||||
|
||||
if (baseID && args.fork) {
|
||||
const forked = await sdk.session.fork({ sessionID: baseID })
|
||||
return forked.data?.id
|
||||
}
|
||||
|
||||
if (baseID) return baseID
|
||||
|
||||
const name = title()
|
||||
const result = await sdk.session.create({ title: name, permission: rules })
|
||||
return result.data?.id
|
||||
}
|
||||
|
||||
async function share(sdk: OpencodeClient, sessionID: string) {
|
||||
const cfg = await sdk.config.get()
|
||||
if (!cfg.data) return
|
||||
if (cfg.data.share !== "auto" && !Flag.OPENCODE_AUTO_SHARE && !args.share) return
|
||||
const res = await sdk.session.share({ sessionID }).catch((error) => {
|
||||
if (error instanceof Error && error.message.includes("disabled")) {
|
||||
UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message)
|
||||
}
|
||||
return { error }
|
||||
})
|
||||
if (!res.error && "data" in res && res.data?.share?.url) {
|
||||
UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + res.data.share.url)
|
||||
}
|
||||
}
|
||||
|
||||
async function execute(sdk: OpencodeClient) {
|
||||
function tool(part: ToolPart) {
|
||||
try {
|
||||
if (part.tool === "bash") return bash(props<typeof BashTool>(part))
|
||||
if (part.tool === "glob") return glob(props<typeof GlobTool>(part))
|
||||
if (part.tool === "grep") return grep(props<typeof GrepTool>(part))
|
||||
if (part.tool === "read") return read(props<typeof ReadTool>(part))
|
||||
if (part.tool === "write") return write(props<typeof WriteTool>(part))
|
||||
if (part.tool === "webfetch") return webfetch(props<typeof WebFetchTool>(part))
|
||||
if (part.tool === "edit") return edit(props<typeof EditTool>(part))
|
||||
if (part.tool === "websearch") return websearch(props<typeof WebSearchTool>(part))
|
||||
if (part.tool === "task") return task(props<typeof TaskTool>(part))
|
||||
if (part.tool === "todowrite") return todo(props<typeof TodoWriteTool>(part))
|
||||
if (part.tool === "skill") return skill(props<typeof SkillTool>(part))
|
||||
return fallback(part)
|
||||
} catch {
|
||||
return fallback(part)
|
||||
}
|
||||
}
|
||||
|
||||
async function execute(sdk: OpencodeClient) {
|
||||
function tool(part: ToolPart) {
|
||||
try {
|
||||
if (part.tool === ShellID.ToolID) return shell(props<typeof ShellTool>(part))
|
||||
if (part.tool === "glob") return glob(props<typeof GlobTool>(part))
|
||||
if (part.tool === "grep") return grep(props<typeof GrepTool>(part))
|
||||
if (part.tool === "read") return read(props<typeof ReadTool>(part))
|
||||
if (part.tool === "write") return write(props<typeof WriteTool>(part))
|
||||
if (part.tool === "webfetch") return webfetch(props<typeof WebFetchTool>(part))
|
||||
if (part.tool === "edit") return edit(props<typeof EditTool>(part))
|
||||
if (part.tool === "websearch") return websearch(props<typeof WebSearchTool>(part))
|
||||
if (part.tool === "task") return task(props<typeof TaskTool>(part))
|
||||
if (part.tool === "todowrite") return todo(props<typeof TodoWriteTool>(part))
|
||||
if (part.tool === "skill") return skill(props<typeof SkillTool>(part))
|
||||
return fallback(part)
|
||||
} catch {
|
||||
return fallback(part)
|
||||
}
|
||||
function emit(type: string, data: Record<string, unknown>) {
|
||||
if (args.format === "json") {
|
||||
process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function emit(type: string, data: Record<string, unknown>) {
|
||||
if (args.format === "json") {
|
||||
process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL)
|
||||
return true
|
||||
const events = await sdk.event.subscribe()
|
||||
let error: string | undefined
|
||||
|
||||
async function loop() {
|
||||
const toggles = new Map<string, boolean>()
|
||||
|
||||
for await (const event of events.stream) {
|
||||
if (
|
||||
event.type === "message.updated" &&
|
||||
event.properties.info.role === "assistant" &&
|
||||
args.format !== "json" &&
|
||||
toggles.get("start") !== true
|
||||
) {
|
||||
UI.empty()
|
||||
UI.println(`> ${event.properties.info.agent} · ${event.properties.info.modelID}`)
|
||||
UI.empty()
|
||||
toggles.set("start", true)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const events = await sdk.event.subscribe()
|
||||
let error: string | undefined
|
||||
if (event.type === "message.part.updated") {
|
||||
const part = event.properties.part
|
||||
if (part.sessionID !== sessionID) continue
|
||||
|
||||
async function loop() {
|
||||
const toggles = new Map<string, boolean>()
|
||||
|
||||
for await (const event of events.stream) {
|
||||
if (
|
||||
event.type === "message.updated" &&
|
||||
event.properties.info.role === "assistant" &&
|
||||
args.format !== "json" &&
|
||||
toggles.get("start") !== true
|
||||
) {
|
||||
UI.empty()
|
||||
UI.println(`> ${event.properties.info.agent} · ${event.properties.info.modelID}`)
|
||||
UI.empty()
|
||||
toggles.set("start", true)
|
||||
}
|
||||
|
||||
if (event.type === "message.part.updated") {
|
||||
const part = event.properties.part
|
||||
if (part.sessionID !== sessionID) continue
|
||||
|
||||
if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) {
|
||||
if (emit("tool_use", { part })) continue
|
||||
if (part.state.status === "completed") {
|
||||
tool(part)
|
||||
continue
|
||||
}
|
||||
inline({
|
||||
icon: "✗",
|
||||
title: `${part.tool} failed`,
|
||||
})
|
||||
UI.error(part.state.error)
|
||||
if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) {
|
||||
if (emit("tool_use", { part })) continue
|
||||
if (part.state.status === "completed") {
|
||||
tool(part)
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
part.type === "tool" &&
|
||||
part.tool === "task" &&
|
||||
part.state.status === "running" &&
|
||||
args.format !== "json"
|
||||
) {
|
||||
if (toggles.get(part.id) === true) continue
|
||||
task(props<typeof TaskTool>(part))
|
||||
toggles.set(part.id, true)
|
||||
}
|
||||
|
||||
if (part.type === "step-start") {
|
||||
if (emit("step_start", { part })) continue
|
||||
}
|
||||
|
||||
if (part.type === "step-finish") {
|
||||
if (emit("step_finish", { part })) continue
|
||||
}
|
||||
|
||||
if (part.type === "text" && part.time?.end) {
|
||||
if (emit("text", { part })) continue
|
||||
const text = part.text.trim()
|
||||
if (!text) continue
|
||||
if (!process.stdout.isTTY) {
|
||||
process.stdout.write(text + EOL)
|
||||
continue
|
||||
}
|
||||
UI.empty()
|
||||
UI.println(text)
|
||||
UI.empty()
|
||||
}
|
||||
|
||||
if (part.type === "reasoning" && part.time?.end && args.thinking) {
|
||||
if (emit("reasoning", { part })) continue
|
||||
const text = part.text.trim()
|
||||
if (!text) continue
|
||||
const line = `Thinking: ${text}`
|
||||
if (process.stdout.isTTY) {
|
||||
UI.empty()
|
||||
UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`)
|
||||
UI.empty()
|
||||
continue
|
||||
}
|
||||
process.stdout.write(line + EOL)
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.error") {
|
||||
const props = event.properties
|
||||
if (props.sessionID !== sessionID || !props.error) continue
|
||||
let err = String(props.error.name)
|
||||
if ("data" in props.error && props.error.data && "message" in props.error.data) {
|
||||
err = String(props.error.data.message)
|
||||
}
|
||||
error = error ? error + EOL + err : err
|
||||
if (emit("error", { error: props.error })) continue
|
||||
UI.error(err)
|
||||
inline({
|
||||
icon: "✗",
|
||||
title: `${part.tool} failed`,
|
||||
})
|
||||
UI.error(part.state.error)
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === "session.status" &&
|
||||
event.properties.sessionID === sessionID &&
|
||||
event.properties.status.type === "idle"
|
||||
part.type === "tool" &&
|
||||
part.tool === "task" &&
|
||||
part.state.status === "running" &&
|
||||
args.format !== "json"
|
||||
) {
|
||||
break
|
||||
if (toggles.get(part.id) === true) continue
|
||||
task(props<typeof TaskTool>(part))
|
||||
toggles.set(part.id, true)
|
||||
}
|
||||
|
||||
if (event.type === "permission.asked") {
|
||||
const permission = event.properties
|
||||
if (permission.sessionID !== sessionID) continue
|
||||
if (part.type === "step-start") {
|
||||
if (emit("step_start", { part })) continue
|
||||
}
|
||||
|
||||
if (args["dangerously-skip-permissions"]) {
|
||||
await sdk.permission.reply({
|
||||
requestID: permission.id,
|
||||
reply: "once",
|
||||
})
|
||||
} else {
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
UI.Style.TEXT_NORMAL +
|
||||
`permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`,
|
||||
)
|
||||
await sdk.permission.reply({
|
||||
requestID: permission.id,
|
||||
reply: "reject",
|
||||
})
|
||||
if (part.type === "step-finish") {
|
||||
if (emit("step_finish", { part })) continue
|
||||
}
|
||||
|
||||
if (part.type === "text" && part.time?.end) {
|
||||
if (emit("text", { part })) continue
|
||||
const text = part.text.trim()
|
||||
if (!text) continue
|
||||
if (!process.stdout.isTTY) {
|
||||
process.stdout.write(text + EOL)
|
||||
continue
|
||||
}
|
||||
UI.empty()
|
||||
UI.println(text)
|
||||
UI.empty()
|
||||
}
|
||||
|
||||
if (part.type === "reasoning" && part.time?.end && args.thinking) {
|
||||
if (emit("reasoning", { part })) continue
|
||||
const text = part.text.trim()
|
||||
if (!text) continue
|
||||
const line = `Thinking: ${text}`
|
||||
if (process.stdout.isTTY) {
|
||||
UI.empty()
|
||||
UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`)
|
||||
UI.empty()
|
||||
continue
|
||||
}
|
||||
process.stdout.write(line + EOL)
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.error") {
|
||||
const props = event.properties
|
||||
if (props.sessionID !== sessionID || !props.error) continue
|
||||
let err = String(props.error.name)
|
||||
if ("data" in props.error && props.error.data && "message" in props.error.data) {
|
||||
err = String(props.error.data.message)
|
||||
}
|
||||
error = error ? error + EOL + err : err
|
||||
if (emit("error", { error: props.error })) continue
|
||||
UI.error(err)
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === "session.status" &&
|
||||
event.properties.sessionID === sessionID &&
|
||||
event.properties.status.type === "idle"
|
||||
) {
|
||||
break
|
||||
}
|
||||
|
||||
if (event.type === "permission.asked") {
|
||||
const permission = event.properties
|
||||
if (permission.sessionID !== sessionID) continue
|
||||
|
||||
if (args["dangerously-skip-permissions"]) {
|
||||
await sdk.permission.reply({
|
||||
requestID: permission.id,
|
||||
reply: "once",
|
||||
})
|
||||
} else {
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
UI.Style.TEXT_NORMAL +
|
||||
`permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`,
|
||||
)
|
||||
await sdk.permission.reply({
|
||||
requestID: permission.id,
|
||||
reply: "reject",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate agent if specified
|
||||
const agent = await (async () => {
|
||||
if (!args.agent) return undefined
|
||||
const name = args.agent
|
||||
// Validate agent if specified
|
||||
const agent = await (async () => {
|
||||
if (!args.agent) return undefined
|
||||
const name = args.agent
|
||||
|
||||
// When attaching, validate against the running server instead of local Instance state.
|
||||
if (args.attach) {
|
||||
const modes = await sdk.app
|
||||
.agents(undefined, { throwOnError: true })
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => undefined)
|
||||
// When attaching, validate against the running server instead of local Instance state.
|
||||
if (args.attach) {
|
||||
const modes = await sdk.app
|
||||
.agents(undefined, { throwOnError: true })
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => undefined)
|
||||
|
||||
if (!modes) {
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
UI.Style.TEXT_NORMAL,
|
||||
`failed to list agents from ${args.attach}. Falling back to default agent`,
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
|
||||
const agent = modes.find((a) => a.name === name)
|
||||
if (!agent) {
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
UI.Style.TEXT_NORMAL,
|
||||
`agent "${name}" not found. Falling back to default agent`,
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (agent.mode === "subagent") {
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
UI.Style.TEXT_NORMAL,
|
||||
`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`,
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
|
||||
return name
|
||||
if (!modes) {
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
UI.Style.TEXT_NORMAL,
|
||||
`failed to list agents from ${args.attach}. Falling back to default agent`,
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
|
||||
const entry = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.get(name)))
|
||||
if (!entry) {
|
||||
const agent = modes.find((a) => a.name === name)
|
||||
if (!agent) {
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
UI.Style.TEXT_NORMAL,
|
||||
@@ -612,7 +582,8 @@ export const RunCommand = effectCmd({
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
if (entry.mode === "subagent") {
|
||||
|
||||
if (agent.mode === "subagent") {
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
UI.Style.TEXT_NORMAL,
|
||||
@@ -620,48 +591,76 @@ export const RunCommand = effectCmd({
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
|
||||
return name
|
||||
})()
|
||||
|
||||
const sessionID = await session(sdk)
|
||||
if (!sessionID) {
|
||||
UI.error("Session not found")
|
||||
process.exit(1)
|
||||
}
|
||||
await share(sdk, sessionID)
|
||||
|
||||
loop().catch((e) => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
const entry = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.get(name)))
|
||||
if (!entry) {
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
UI.Style.TEXT_NORMAL,
|
||||
`agent "${name}" not found. Falling back to default agent`,
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
if (entry.mode === "subagent") {
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
UI.Style.TEXT_NORMAL,
|
||||
`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`,
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
return name
|
||||
})()
|
||||
|
||||
const sessionID = await session(sdk)
|
||||
if (!sessionID) {
|
||||
UI.error("Session not found")
|
||||
process.exit(1)
|
||||
}
|
||||
await share(sdk, sessionID)
|
||||
|
||||
loop().catch((e) => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
if (args.command) {
|
||||
await sdk.session.command({
|
||||
sessionID,
|
||||
agent,
|
||||
model: args.model,
|
||||
command: args.command,
|
||||
arguments: message,
|
||||
variant: args.variant,
|
||||
})
|
||||
} else {
|
||||
const model = args.model ? Provider.parseModel(args.model) : undefined
|
||||
await sdk.session.prompt({
|
||||
sessionID,
|
||||
agent,
|
||||
model,
|
||||
variant: args.variant,
|
||||
parts: [...files, { type: "text", text: message }],
|
||||
})
|
||||
|
||||
if (args.command) {
|
||||
await sdk.session.command({
|
||||
sessionID,
|
||||
agent,
|
||||
model: args.model,
|
||||
command: args.command,
|
||||
arguments: message,
|
||||
variant: args.variant,
|
||||
})
|
||||
} else {
|
||||
const model = args.model ? Provider.parseModel(args.model) : undefined
|
||||
await sdk.session.prompt({
|
||||
sessionID,
|
||||
agent,
|
||||
model,
|
||||
variant: args.variant,
|
||||
parts: [...files, { type: "text", text: message }],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (args.attach) {
|
||||
const headers = ServerAuth.headers({ password: args.password })
|
||||
const sdk = createOpencodeClient({ baseUrl: args.attach, directory, headers })
|
||||
return await execute(sdk)
|
||||
}
|
||||
if (args.attach) {
|
||||
const headers = (() => {
|
||||
const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD
|
||||
if (!password) return undefined
|
||||
const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode"
|
||||
const auth = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
|
||||
return { Authorization: auth }
|
||||
})()
|
||||
const sdk = createOpencodeClient({ baseUrl: args.attach, directory, headers })
|
||||
return await execute(sdk)
|
||||
}
|
||||
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = new Request(input, init)
|
||||
return Server.Default().app.fetch(request)
|
||||
@@ -669,5 +668,5 @@ export const RunCommand = effectCmd({
|
||||
const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn })
|
||||
await execute(sdk)
|
||||
})
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
import { Effect } from "effect"
|
||||
import { Server } from "../../server/server"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
import { cmd } from "./cmd"
|
||||
import { withNetworkOptions, resolveNetworkOptions } from "../network"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
|
||||
export const ServeCommand = effectCmd({
|
||||
export const ServeCommand = cmd({
|
||||
command: "serve",
|
||||
builder: (yargs) => withNetworkOptions(yargs),
|
||||
describe: "starts a headless opencode server",
|
||||
// Server loads instances per-request via x-opencode-directory header — no
|
||||
// need for an ambient project InstanceContext at startup.
|
||||
instance: false,
|
||||
handler: Effect.fn("Cli.serve")(function* (args) {
|
||||
handler: async (args) => {
|
||||
if (!Flag.OPENCODE_SERVER_PASSWORD) {
|
||||
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
|
||||
}
|
||||
const opts = yield* Effect.promise(() => resolveNetworkOptions(args))
|
||||
const server = yield* Effect.promise(() => Server.listen(opts))
|
||||
const opts = await resolveNetworkOptions(args)
|
||||
const server = await Server.listen(opts)
|
||||
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
|
||||
|
||||
yield* Effect.never
|
||||
}),
|
||||
await new Promise(() => {})
|
||||
await server.stop()
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { Argv } from "yargs"
|
||||
import { Effect } from "effect"
|
||||
import { cmd } from "./cmd"
|
||||
import { effectCmd, fail } from "../effect-cmd"
|
||||
import { Session } from "@/session/session"
|
||||
import { SessionID } from "../../session/schema"
|
||||
import { bootstrap } from "../bootstrap"
|
||||
import { UI } from "../ui"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
@@ -12,6 +11,7 @@ import { Process } from "@/util/process"
|
||||
import { EOL } from "os"
|
||||
import path from "path"
|
||||
import { which } from "../../util/which"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
|
||||
function pagerCmd(): string[] {
|
||||
const lessOptions = ["-R", "-S"]
|
||||
@@ -47,30 +47,36 @@ export const SessionCommand = cmd({
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
export const SessionDeleteCommand = effectCmd({
|
||||
export const SessionDeleteCommand = cmd({
|
||||
command: "delete <sessionID>",
|
||||
describe: "delete a session",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("sessionID", {
|
||||
builder: (yargs: Argv) => {
|
||||
return yargs.positional("sessionID", {
|
||||
describe: "session ID to delete",
|
||||
type: "string",
|
||||
demandOption: true,
|
||||
}),
|
||||
handler: Effect.fn("Cli.session.delete")(function* (args) {
|
||||
const svc = yield* Session.Service
|
||||
const sessionID = SessionID.make(args.sessionID)
|
||||
// Match legacy try/catch — Session.get surfaces NotFoundError as a defect.
|
||||
yield* svc.get(sessionID).pipe(Effect.catchCause(() => fail(`Session not found: ${args.sessionID}`)))
|
||||
yield* svc.remove(sessionID)
|
||||
UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Session ${args.sessionID} deleted` + UI.Style.TEXT_NORMAL)
|
||||
}),
|
||||
})
|
||||
},
|
||||
handler: async (args) => {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const sessionID = SessionID.make(args.sessionID)
|
||||
try {
|
||||
await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID)))
|
||||
} catch {
|
||||
UI.error(`Session not found: ${args.sessionID}`)
|
||||
process.exit(1)
|
||||
}
|
||||
await AppRuntime.runPromise(Session.Service.use((svc) => svc.remove(sessionID)))
|
||||
UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Session ${args.sessionID} deleted` + UI.Style.TEXT_NORMAL)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const SessionListCommand = effectCmd({
|
||||
export const SessionListCommand = cmd({
|
||||
command: "list",
|
||||
describe: "list sessions",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
builder: (yargs: Argv) => {
|
||||
return yargs
|
||||
.option("max-count", {
|
||||
alias: "n",
|
||||
describe: "limit to N most recent sessions",
|
||||
@@ -81,18 +87,28 @@ export const SessionListCommand = effectCmd({
|
||||
type: "string",
|
||||
choices: ["table", "json"],
|
||||
default: "table",
|
||||
}),
|
||||
handler: Effect.fn("Cli.session.list")(function* (args) {
|
||||
const sessions = yield* Session.Service.use((svc) => svc.list({ roots: true, limit: args.maxCount }))
|
||||
})
|
||||
},
|
||||
handler: async (args) => {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const sessions = await AppRuntime.runPromise(
|
||||
Session.Service.use((svc) => svc.list({ roots: true, limit: args.maxCount })),
|
||||
)
|
||||
|
||||
if (sessions.length === 0) return
|
||||
if (sessions.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const output = args.format === "json" ? formatSessionJSON(sessions) : formatSessionTable(sessions)
|
||||
let output: string
|
||||
if (args.format === "json") {
|
||||
output = formatSessionJSON(sessions)
|
||||
} else {
|
||||
output = formatSessionTable(sessions)
|
||||
}
|
||||
|
||||
const shouldPaginate = process.stdout.isTTY && !args.maxCount && args.format === "table"
|
||||
const shouldPaginate = process.stdout.isTTY && !args.maxCount && args.format === "table"
|
||||
|
||||
if (shouldPaginate) {
|
||||
yield* Effect.promise(async () => {
|
||||
if (shouldPaginate) {
|
||||
const proc = Process.spawn(pagerCmd(), {
|
||||
stdin: "pipe",
|
||||
stdout: "inherit",
|
||||
@@ -107,11 +123,11 @@ export const SessionListCommand = effectCmd({
|
||||
proc.stdin.write(output)
|
||||
proc.stdin.end()
|
||||
await proc.exited
|
||||
})
|
||||
} else {
|
||||
console.log(output)
|
||||
}
|
||||
}),
|
||||
} else {
|
||||
console.log(output)
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
function formatSessionTable(sessions: Session.Info[]): string {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Effect } from "effect"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
import type { Argv } from "yargs"
|
||||
import { cmd } from "./cmd"
|
||||
import { Session } from "@/session/session"
|
||||
import { bootstrap } from "../bootstrap"
|
||||
import { Database } from "@/storage/db"
|
||||
import { SessionTable } from "../../session/session.sql"
|
||||
import { Project } from "@/project/project"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { Instance } from "../../project/instance"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
|
||||
interface SessionStats {
|
||||
totalSessions: number
|
||||
@@ -45,11 +47,11 @@ interface SessionStats {
|
||||
medianTokensPerSession: number
|
||||
}
|
||||
|
||||
export const StatsCommand = effectCmd({
|
||||
export const StatsCommand = cmd({
|
||||
command: "stats",
|
||||
describe: "show token usage and cost statistics",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
builder: (yargs: Argv) => {
|
||||
return yargs
|
||||
.option("days", {
|
||||
describe: "show stats for the last N days (default: all time)",
|
||||
type: "number",
|
||||
@@ -64,32 +66,35 @@ export const StatsCommand = effectCmd({
|
||||
.option("project", {
|
||||
describe: "filter by project (default: all projects, empty string: current project)",
|
||||
type: "string",
|
||||
}),
|
||||
handler: Effect.fn("Cli.stats")(function* (args) {
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return
|
||||
const stats = yield* aggregateSessionStats(args.days, args.project, ctx.project)
|
||||
let modelLimit: number | undefined
|
||||
if (args.models === true) {
|
||||
modelLimit = Infinity
|
||||
} else if (typeof args.models === "number") {
|
||||
modelLimit = args.models
|
||||
}
|
||||
displayStats(stats, args.tools, modelLimit)
|
||||
}),
|
||||
})
|
||||
},
|
||||
handler: async (args) => {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const stats = await aggregateSessionStats(args.days, args.project)
|
||||
|
||||
let modelLimit: number | undefined
|
||||
if (args.models === true) {
|
||||
modelLimit = Infinity
|
||||
} else if (typeof args.models === "number") {
|
||||
modelLimit = args.models
|
||||
}
|
||||
|
||||
displayStats(stats, args.tools, modelLimit)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const getAllSessions = Effect.sync(() =>
|
||||
Database.use((db) => db.select().from(SessionTable).all()).map((row) => Session.fromRow(row)),
|
||||
)
|
||||
async function getCurrentProject(): Promise<Project.Info> {
|
||||
return Instance.project
|
||||
}
|
||||
|
||||
const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* (
|
||||
days?: number,
|
||||
projectFilter?: string,
|
||||
currentProject?: Project.Info,
|
||||
) {
|
||||
const svc = yield* Session.Service
|
||||
const sessions = yield* getAllSessions
|
||||
async function getAllSessions(): Promise<Session.Info[]> {
|
||||
const rows = Database.use((db) => db.select().from(SessionTable).all())
|
||||
return rows.map((row) => Session.fromRow(row))
|
||||
}
|
||||
|
||||
export async function aggregateSessionStats(days?: number, projectFilter?: string): Promise<SessionStats> {
|
||||
const sessions = await getAllSessions()
|
||||
const MS_IN_DAY = 24 * 60 * 60 * 1000
|
||||
|
||||
const cutoffTime = (() => {
|
||||
@@ -112,7 +117,7 @@ const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* (
|
||||
|
||||
if (projectFilter !== undefined) {
|
||||
if (projectFilter === "") {
|
||||
if (!currentProject) throw new Error("currentProject required when projectFilter is empty string")
|
||||
const currentProject = await getCurrentProject()
|
||||
filteredSessions = filteredSessions.filter((session) => session.projectID === currentProject.id)
|
||||
} else {
|
||||
filteredSessions = filteredSessions.filter((session) => session.projectID === projectFilter)
|
||||
@@ -158,111 +163,122 @@ const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* (
|
||||
|
||||
const sessionTotalTokens: number[] = []
|
||||
|
||||
const results = yield* Effect.forEach(
|
||||
filteredSessions,
|
||||
(session) =>
|
||||
Effect.gen(function* () {
|
||||
const messages = yield* svc.messages({ sessionID: session.id })
|
||||
const BATCH_SIZE = 20
|
||||
for (let i = 0; i < filteredSessions.length; i += BATCH_SIZE) {
|
||||
const batch = filteredSessions.slice(i, i + BATCH_SIZE)
|
||||
|
||||
let sessionCost = 0
|
||||
let sessionTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
|
||||
let sessionToolUsage: Record<string, number> = {}
|
||||
let sessionModelUsage: Record<
|
||||
string,
|
||||
{
|
||||
messages: number
|
||||
tokens: { input: number; output: number; cache: { read: number; write: number } }
|
||||
cost: number
|
||||
}
|
||||
> = {}
|
||||
const batchPromises = batch.map(async (session) => {
|
||||
const messages = await AppRuntime.runPromise(
|
||||
Session.Service.use((svc) => svc.messages({ sessionID: session.id })),
|
||||
)
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.info.role === "assistant") {
|
||||
sessionCost += message.info.cost || 0
|
||||
|
||||
const modelKey = `${message.info.providerID}/${message.info.modelID}`
|
||||
if (!sessionModelUsage[modelKey]) {
|
||||
sessionModelUsage[modelKey] = {
|
||||
messages: 0,
|
||||
tokens: { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
||||
cost: 0,
|
||||
}
|
||||
}
|
||||
sessionModelUsage[modelKey].messages++
|
||||
sessionModelUsage[modelKey].cost += message.info.cost || 0
|
||||
|
||||
if (message.info.tokens) {
|
||||
sessionTokens.input += message.info.tokens.input || 0
|
||||
sessionTokens.output += message.info.tokens.output || 0
|
||||
sessionTokens.reasoning += message.info.tokens.reasoning || 0
|
||||
sessionTokens.cache.read += message.info.tokens.cache?.read || 0
|
||||
sessionTokens.cache.write += message.info.tokens.cache?.write || 0
|
||||
|
||||
sessionModelUsage[modelKey].tokens.input += message.info.tokens.input || 0
|
||||
sessionModelUsage[modelKey].tokens.output +=
|
||||
(message.info.tokens.output || 0) + (message.info.tokens.reasoning || 0)
|
||||
sessionModelUsage[modelKey].tokens.cache.read += message.info.tokens.cache?.read || 0
|
||||
sessionModelUsage[modelKey].tokens.cache.write += message.info.tokens.cache?.write || 0
|
||||
let sessionCost = 0
|
||||
let sessionTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
|
||||
let sessionToolUsage: Record<string, number> = {}
|
||||
let sessionModelUsage: Record<
|
||||
string,
|
||||
{
|
||||
messages: number
|
||||
tokens: {
|
||||
input: number
|
||||
output: number
|
||||
cache: {
|
||||
read: number
|
||||
write: number
|
||||
}
|
||||
}
|
||||
cost: number
|
||||
}
|
||||
> = {}
|
||||
|
||||
for (const part of message.parts) {
|
||||
if (part.type === "tool" && part.tool) {
|
||||
sessionToolUsage[part.tool] = (sessionToolUsage[part.tool] || 0) + 1
|
||||
for (const message of messages) {
|
||||
if (message.info.role === "assistant") {
|
||||
sessionCost += message.info.cost || 0
|
||||
|
||||
const modelKey = `${message.info.providerID}/${message.info.modelID}`
|
||||
if (!sessionModelUsage[modelKey]) {
|
||||
sessionModelUsage[modelKey] = {
|
||||
messages: 0,
|
||||
tokens: { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
||||
cost: 0,
|
||||
}
|
||||
}
|
||||
sessionModelUsage[modelKey].messages++
|
||||
sessionModelUsage[modelKey].cost += message.info.cost || 0
|
||||
|
||||
if (message.info.tokens) {
|
||||
sessionTokens.input += message.info.tokens.input || 0
|
||||
sessionTokens.output += message.info.tokens.output || 0
|
||||
sessionTokens.reasoning += message.info.tokens.reasoning || 0
|
||||
sessionTokens.cache.read += message.info.tokens.cache?.read || 0
|
||||
sessionTokens.cache.write += message.info.tokens.cache?.write || 0
|
||||
|
||||
sessionModelUsage[modelKey].tokens.input += message.info.tokens.input || 0
|
||||
sessionModelUsage[modelKey].tokens.output +=
|
||||
(message.info.tokens.output || 0) + (message.info.tokens.reasoning || 0)
|
||||
sessionModelUsage[modelKey].tokens.cache.read += message.info.tokens.cache?.read || 0
|
||||
sessionModelUsage[modelKey].tokens.cache.write += message.info.tokens.cache?.write || 0
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
messageCount: messages.length,
|
||||
sessionCost,
|
||||
sessionTokens,
|
||||
sessionTotalTokens:
|
||||
sessionTokens.input +
|
||||
sessionTokens.output +
|
||||
sessionTokens.reasoning +
|
||||
sessionTokens.cache.read +
|
||||
sessionTokens.cache.write,
|
||||
sessionToolUsage,
|
||||
sessionModelUsage,
|
||||
earliestTime: cutoffTime > 0 ? session.time.updated : session.time.created,
|
||||
latestTime: session.time.updated,
|
||||
}
|
||||
}),
|
||||
{ concurrency: 20 },
|
||||
)
|
||||
|
||||
for (const result of results) {
|
||||
earliestTime = Math.min(earliestTime, result.earliestTime)
|
||||
latestTime = Math.max(latestTime, result.latestTime)
|
||||
sessionTotalTokens.push(result.sessionTotalTokens)
|
||||
|
||||
stats.totalMessages += result.messageCount
|
||||
stats.totalCost += result.sessionCost
|
||||
stats.totalTokens.input += result.sessionTokens.input
|
||||
stats.totalTokens.output += result.sessionTokens.output
|
||||
stats.totalTokens.reasoning += result.sessionTokens.reasoning
|
||||
stats.totalTokens.cache.read += result.sessionTokens.cache.read
|
||||
stats.totalTokens.cache.write += result.sessionTokens.cache.write
|
||||
|
||||
for (const [tool, count] of Object.entries(result.sessionToolUsage)) {
|
||||
stats.toolUsage[tool] = (stats.toolUsage[tool] || 0) + count
|
||||
}
|
||||
|
||||
for (const [model, usage] of Object.entries(result.sessionModelUsage)) {
|
||||
if (!stats.modelUsage[model]) {
|
||||
stats.modelUsage[model] = {
|
||||
messages: 0,
|
||||
tokens: { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
||||
cost: 0,
|
||||
for (const part of message.parts) {
|
||||
if (part.type === "tool" && part.tool) {
|
||||
sessionToolUsage[part.tool] = (sessionToolUsage[part.tool] || 0) + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
stats.modelUsage[model].messages += usage.messages
|
||||
stats.modelUsage[model].tokens.input += usage.tokens.input
|
||||
stats.modelUsage[model].tokens.output += usage.tokens.output
|
||||
stats.modelUsage[model].tokens.cache.read += usage.tokens.cache.read
|
||||
stats.modelUsage[model].tokens.cache.write += usage.tokens.cache.write
|
||||
stats.modelUsage[model].cost += usage.cost
|
||||
|
||||
return {
|
||||
messageCount: messages.length,
|
||||
sessionCost,
|
||||
sessionTokens,
|
||||
sessionTotalTokens:
|
||||
sessionTokens.input +
|
||||
sessionTokens.output +
|
||||
sessionTokens.reasoning +
|
||||
sessionTokens.cache.read +
|
||||
sessionTokens.cache.write,
|
||||
sessionToolUsage,
|
||||
sessionModelUsage,
|
||||
earliestTime: cutoffTime > 0 ? session.time.updated : session.time.created,
|
||||
latestTime: session.time.updated,
|
||||
}
|
||||
})
|
||||
|
||||
const batchResults = await Promise.all(batchPromises)
|
||||
|
||||
for (const result of batchResults) {
|
||||
earliestTime = Math.min(earliestTime, result.earliestTime)
|
||||
latestTime = Math.max(latestTime, result.latestTime)
|
||||
sessionTotalTokens.push(result.sessionTotalTokens)
|
||||
|
||||
stats.totalMessages += result.messageCount
|
||||
stats.totalCost += result.sessionCost
|
||||
stats.totalTokens.input += result.sessionTokens.input
|
||||
stats.totalTokens.output += result.sessionTokens.output
|
||||
stats.totalTokens.reasoning += result.sessionTokens.reasoning
|
||||
stats.totalTokens.cache.read += result.sessionTokens.cache.read
|
||||
stats.totalTokens.cache.write += result.sessionTokens.cache.write
|
||||
|
||||
for (const [tool, count] of Object.entries(result.sessionToolUsage)) {
|
||||
stats.toolUsage[tool] = (stats.toolUsage[tool] || 0) + count
|
||||
}
|
||||
|
||||
for (const [model, usage] of Object.entries(result.sessionModelUsage)) {
|
||||
if (!stats.modelUsage[model]) {
|
||||
stats.modelUsage[model] = {
|
||||
messages: 0,
|
||||
tokens: { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
||||
cost: 0,
|
||||
}
|
||||
}
|
||||
stats.modelUsage[model].messages += usage.messages
|
||||
stats.modelUsage[model].tokens.input += usage.tokens.input
|
||||
stats.modelUsage[model].tokens.output += usage.tokens.output
|
||||
stats.modelUsage[model].tokens.cache.read += usage.tokens.cache.read
|
||||
stats.modelUsage[model].tokens.cache.write += usage.tokens.cache.write
|
||||
stats.modelUsage[model].cost += usage.cost
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,7 +307,7 @@ const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* (
|
||||
: sessionTotalTokens[mid]
|
||||
|
||||
return stats
|
||||
})
|
||||
}
|
||||
|
||||
export function displayStats(stats: SessionStats, toolLimit?: number, modelLimit?: number) {
|
||||
const width = 56
|
||||
|
||||
@@ -28,7 +28,6 @@ import { useEvent } from "@tui/context/event"
|
||||
import { SDKProvider, useSDK } from "@tui/context/sdk"
|
||||
import { StartupLoading } from "@tui/component/startup-loading"
|
||||
import { SyncProvider, useSync } from "@tui/context/sync"
|
||||
import { SyncProviderV2 } from "@tui/context/sync-v2"
|
||||
import { LocalProvider, useLocal } from "@tui/context/local"
|
||||
import { DialogModel } from "@tui/component/dialog-model"
|
||||
import { useConnected } from "@tui/component/use-connected"
|
||||
@@ -169,29 +168,27 @@ export function tui(input: {
|
||||
>
|
||||
<ProjectProvider>
|
||||
<SyncProvider>
|
||||
<SyncProviderV2>
|
||||
<ThemeProvider mode={mode}>
|
||||
<LocalProvider>
|
||||
<KeybindProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
<CommandProvider>
|
||||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<EditorContextProvider>
|
||||
<App onSnapshot={input.onSnapshot} />
|
||||
</EditorContextProvider>
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
</CommandProvider>
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</KeybindProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
</SyncProviderV2>
|
||||
<ThemeProvider mode={mode}>
|
||||
<LocalProvider>
|
||||
<KeybindProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
<CommandProvider>
|
||||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<EditorContextProvider>
|
||||
<App onSnapshot={input.onSnapshot} />
|
||||
</EditorContextProvider>
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
</CommandProvider>
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</KeybindProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
</SyncProvider>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
@@ -779,15 +776,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: kv.get("clear_prompt_save_history", false) ? "Don't include cleared prompts in history" : "Include cleared prompts in history",
|
||||
value: "app.toggle.clear_prompt_history",
|
||||
category: "System",
|
||||
onSelect: (dialog) => {
|
||||
kv.set("clear_prompt_save_history", !kv.get("clear_prompt_save_history", false))
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
event.on(TuiEvent.CommandExecute.type, (evt) => {
|
||||
|
||||
@@ -5,7 +5,6 @@ import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import { validateSession } from "./validate-session"
|
||||
import { ServerAuth } from "@/server/auth"
|
||||
|
||||
export const AttachCommand = cmd({
|
||||
command: "attach <url>",
|
||||
@@ -39,11 +38,6 @@ export const AttachCommand = cmd({
|
||||
alias: ["p"],
|
||||
type: "string",
|
||||
describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)",
|
||||
})
|
||||
.option("username", {
|
||||
alias: ["u"],
|
||||
type: "string",
|
||||
describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')",
|
||||
}),
|
||||
handler: async (args) => {
|
||||
const unguard = win32InstallCtrlCGuard()
|
||||
@@ -66,7 +60,12 @@ export const AttachCommand = cmd({
|
||||
return args.dir
|
||||
}
|
||||
})()
|
||||
const headers = ServerAuth.headers({ password: args.password, username: args.username })
|
||||
const headers = (() => {
|
||||
const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD
|
||||
if (!password) return undefined
|
||||
const auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
|
||||
return { Authorization: auth }
|
||||
})()
|
||||
const config = await TuiConfig.get()
|
||||
|
||||
try {
|
||||
|
||||
@@ -82,7 +82,6 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create
|
||||
return store.history.at(store.index)
|
||||
},
|
||||
append(item: PromptInfo) {
|
||||
if (store.history.at(-1)?.input === item.input) return
|
||||
const entry = structuredClone(unwrap(item))
|
||||
let trimmed = false
|
||||
setStore(
|
||||
|
||||
@@ -173,7 +173,6 @@ export function Prompt(props: PromptProps) {
|
||||
const [editorContextHover, setEditorContextHover] = createSignal(false)
|
||||
let lastSubmittedEditorSelectionKey: string | undefined
|
||||
const [auto, setAuto] = createSignal<AutocompleteRef>()
|
||||
const [autoaccept, setAutoaccept] = kv.signal<"none" | "edit">("permission_auto_accept", "edit")
|
||||
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
|
||||
const hasRightContent = createMemo(() => Boolean(props.right))
|
||||
|
||||
@@ -297,17 +296,6 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
command.register(() => {
|
||||
return [
|
||||
{
|
||||
title: autoaccept() === "none" ? "Enable autoedit" : "Disable autoedit",
|
||||
value: "permission.auto_accept.toggle",
|
||||
search: "toggle permissions",
|
||||
keybind: "permission_auto_accept_toggle",
|
||||
category: "Agent",
|
||||
onSelect: (dialog) => {
|
||||
setAutoaccept(() => (autoaccept() === "none" ? "edit" : "none"))
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Clear prompt",
|
||||
value: "prompt.clear",
|
||||
@@ -762,18 +750,9 @@ export function Prompt(props: PromptProps) {
|
||||
return false
|
||||
}
|
||||
|
||||
const variant = local.model.variant.current()
|
||||
let sessionID = props.sessionID
|
||||
if (sessionID == null) {
|
||||
const res = await sdk.client.session.create({
|
||||
workspace: props.workspaceID,
|
||||
agent: agent.name,
|
||||
model: {
|
||||
providerID: selectedModel.providerID,
|
||||
id: selectedModel.modelID,
|
||||
variant,
|
||||
},
|
||||
})
|
||||
const res = await sdk.client.session.create({ workspace: props.workspaceID })
|
||||
|
||||
if (res.error) {
|
||||
console.log("Creating a session failed:", res.error)
|
||||
@@ -813,6 +792,7 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
// Capture mode before it gets reset
|
||||
const currentMode = store.mode
|
||||
const variant = local.model.variant.current()
|
||||
const editorSelection = editorContext()
|
||||
const currentEditorSelectionKey = editorSelectionKey(editorSelection)
|
||||
const editorParts =
|
||||
@@ -1136,12 +1116,6 @@ export function Prompt(props: PromptProps) {
|
||||
// If no image, let the default paste behavior continue
|
||||
}
|
||||
if (keybind.match("input_clear", e) && store.prompt.input !== "") {
|
||||
if (kv.get("clear_prompt_save_history", false)) {
|
||||
history.append({
|
||||
...store.prompt,
|
||||
mode: store.mode,
|
||||
})
|
||||
}
|
||||
input.clear()
|
||||
input.extmarks.clear()
|
||||
setStore("prompt", {
|
||||
@@ -1336,11 +1310,6 @@ export function Prompt(props: PromptProps) {
|
||||
</box>
|
||||
<Show when={hasRightContent()}>
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<Show when={autoaccept() === "edit"}>
|
||||
<text>
|
||||
<span style={{ fg: theme.warning }}>autoedit</span>
|
||||
</text>
|
||||
</Show>
|
||||
{props.right}
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
@@ -68,73 +68,29 @@ function normalize(raw: Record<string, unknown>) {
|
||||
}
|
||||
}
|
||||
|
||||
async function resolvePlugins(config: Info, configFilepath: string) {
|
||||
if (!config.plugin) return config
|
||||
for (let i = 0; i < config.plugin.length; i++) {
|
||||
config.plugin[i] = await ConfigPlugin.resolvePluginSpec(config.plugin[i], configFilepath)
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
async function mergeFile(acc: Acc, file: string, ctx: { directory: string }) {
|
||||
const data = await loadFile(file)
|
||||
acc.result = mergeDeep(acc.result, data)
|
||||
if (!data.plugin?.length) return
|
||||
|
||||
const scope = pluginScope(file, ctx)
|
||||
const plugins = ConfigPlugin.deduplicatePluginOrigins([
|
||||
...(acc.result.plugin_origins ?? []),
|
||||
...data.plugin.map((spec) => ({ spec, scope, source: file })),
|
||||
])
|
||||
acc.result.plugin = plugins.map((item) => item.spec)
|
||||
acc.result.plugin_origins = plugins
|
||||
}
|
||||
|
||||
const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: string }) {
|
||||
const afs = yield* AppFileSystem.Service
|
||||
|
||||
const resolvePlugins = (config: Info, configFilepath: string): Effect.Effect<Info> =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = config.plugin
|
||||
if (!plugins) return config
|
||||
for (let i = 0; i < plugins.length; i++) {
|
||||
plugins[i] = yield* Effect.promise(() => ConfigPlugin.resolvePluginSpec(plugins[i], configFilepath))
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
const load = (text: string, configFilepath: string): Effect.Effect<Info> =>
|
||||
Effect.gen(function* () {
|
||||
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
|
||||
// (mirroring the old opencode.json shape) still get their settings applied.
|
||||
const validated = ConfigParse.schema(Info, normalize(data), configFilepath)
|
||||
return yield* resolvePlugins(validated, configFilepath)
|
||||
}).pipe(
|
||||
// catchCause (not tapErrorCause + orElseSucceed) because ConfigParse.jsonc/.schema
|
||||
// can sync-throw — those become defects, which orElseSucceed wouldn't catch.
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.sync(() => {
|
||||
log.warn("invalid tui config", { path: configFilepath, cause })
|
||||
return {} as Info
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const loadFile = (filepath: string): Effect.Effect<Info> =>
|
||||
Effect.gen(function* () {
|
||||
// Silent-swallow non-NotFound read errors (perms, EISDIR, IO) → log + skip.
|
||||
// Matches how parse/schema/plugin failures in load() are handled — every
|
||||
// broken-config path degrades gracefully rather than crashing TUI startup.
|
||||
const text = yield* afs.readFileStringSafe(filepath).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.sync(() => {
|
||||
log.warn("failed to read tui config", { path: filepath, cause })
|
||||
return undefined
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (!text) return {} as Info
|
||||
return yield* load(text, filepath)
|
||||
})
|
||||
|
||||
const mergeFile = (acc: Acc, file: string) =>
|
||||
Effect.gen(function* () {
|
||||
const data = yield* loadFile(file)
|
||||
acc.result = mergeDeep(acc.result, data)
|
||||
if (!data.plugin?.length) return
|
||||
|
||||
const scope = pluginScope(file, ctx)
|
||||
const plugins = ConfigPlugin.deduplicatePluginOrigins([
|
||||
...(acc.result.plugin_origins ?? []),
|
||||
...data.plugin.map((spec) => ({ spec, scope, source: file })),
|
||||
])
|
||||
acc.result.plugin = plugins.map((item) => item.spec)
|
||||
acc.result.plugin_origins = plugins
|
||||
})
|
||||
|
||||
// Every config dir we may read from: global config dir, any `.opencode`
|
||||
// folders between cwd and home, and OPENCODE_CONFIG_DIR.
|
||||
const directories = yield* ConfigPaths.directories(ctx.directory)
|
||||
@@ -148,19 +104,19 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
|
||||
|
||||
// 1. Global tui config (lowest precedence).
|
||||
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
|
||||
yield* mergeFile(acc, file)
|
||||
yield* Effect.promise(() => mergeFile(acc, file, ctx)).pipe(Effect.orDie)
|
||||
}
|
||||
|
||||
// 2. Explicit OPENCODE_TUI_CONFIG override, if set.
|
||||
if (Flag.OPENCODE_TUI_CONFIG) {
|
||||
const configFile = Flag.OPENCODE_TUI_CONFIG
|
||||
yield* mergeFile(acc, configFile)
|
||||
yield* Effect.promise(() => mergeFile(acc, configFile, ctx)).pipe(Effect.orDie)
|
||||
log.debug("loaded custom tui config", { path: configFile })
|
||||
}
|
||||
|
||||
// 3. Project tui files, applied root-first so the closest file wins.
|
||||
for (const file of projectFiles) {
|
||||
yield* mergeFile(acc, file)
|
||||
yield* Effect.promise(() => mergeFile(acc, file, ctx)).pipe(Effect.orDie)
|
||||
}
|
||||
|
||||
// 4. `.opencode` directories (and OPENCODE_CONFIG_DIR) discovered while
|
||||
@@ -171,7 +127,7 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
|
||||
for (const dir of dirs) {
|
||||
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
|
||||
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
|
||||
yield* mergeFile(acc, file)
|
||||
yield* Effect.promise(() => mergeFile(acc, file, ctx)).pipe(Effect.orDie)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,3 +193,28 @@ export async function get() {
|
||||
return runPromise((svc) => svc.get())
|
||||
}
|
||||
|
||||
async function loadFile(filepath: string): Promise<Info> {
|
||||
const text = await ConfigPaths.readFile(filepath)
|
||||
if (!text) return {}
|
||||
return load(text, filepath).catch((error) => {
|
||||
log.warn("failed to load tui config", { path: filepath, error })
|
||||
return {}
|
||||
})
|
||||
}
|
||||
|
||||
async function load(text: string, configFilepath: string): Promise<Info> {
|
||||
return ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty" })
|
||||
.then((expanded) => ConfigParse.jsonc(expanded, configFilepath))
|
||||
.then((data) => {
|
||||
if (!isRecord(data)) return {}
|
||||
|
||||
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
|
||||
// (mirroring the old opencode.json shape) still get their settings applied.
|
||||
return ConfigParse.schema(Info, normalize(data), configFilepath)
|
||||
})
|
||||
.then((data) => resolvePlugins(data, configFilepath))
|
||||
.catch((error) => {
|
||||
log.warn("invalid tui config", { path: configFilepath, error })
|
||||
return {}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,298 +0,0 @@
|
||||
import { useEvent } from "@tui/context/event"
|
||||
import type {
|
||||
SessionMessage,
|
||||
SessionMessageAssistant,
|
||||
SessionMessageAssistantReasoning,
|
||||
SessionMessageAssistantText,
|
||||
SessionMessageAssistantTool,
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useSDK } from "./sdk"
|
||||
|
||||
function activeAssistant(messages: SessionMessage[]) {
|
||||
const index = messages.findLastIndex((message) => message.type === "assistant" && !message.time.completed)
|
||||
if (index < 0) return
|
||||
const assistant = messages[index]
|
||||
return assistant?.type === "assistant" ? assistant : undefined
|
||||
}
|
||||
|
||||
function activeCompaction(messages: SessionMessage[]) {
|
||||
const index = messages.findLastIndex((message) => message.type === "compaction")
|
||||
if (index < 0) return
|
||||
const compaction = messages[index]
|
||||
return compaction?.type === "compaction" ? compaction : undefined
|
||||
}
|
||||
|
||||
function activeShell(messages: SessionMessage[], callID: string) {
|
||||
const index = messages.findLastIndex((message) => message.type === "shell" && message.callID === callID)
|
||||
if (index < 0) return
|
||||
const shell = messages[index]
|
||||
return shell?.type === "shell" ? shell : undefined
|
||||
}
|
||||
|
||||
function latestTool(assistant: SessionMessageAssistant | undefined, callID?: string) {
|
||||
return assistant?.content.findLast(
|
||||
(item): item is SessionMessageAssistantTool => item.type === "tool" && (callID === undefined || item.id === callID),
|
||||
)
|
||||
}
|
||||
|
||||
function latestText(assistant: SessionMessageAssistant | undefined) {
|
||||
return assistant?.content.findLast((item): item is SessionMessageAssistantText => item.type === "text")
|
||||
}
|
||||
|
||||
function latestReasoning(assistant: SessionMessageAssistant | undefined, reasoningID: string) {
|
||||
return assistant?.content.findLast(
|
||||
(item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && item.id === reasoningID,
|
||||
)
|
||||
}
|
||||
|
||||
export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext({
|
||||
name: "SyncV2",
|
||||
init: () => {
|
||||
const [store, setStore] = createStore<{
|
||||
messages: {
|
||||
[sessionID: string]: SessionMessage[]
|
||||
}
|
||||
}>({
|
||||
messages: {},
|
||||
})
|
||||
|
||||
const event = useEvent()
|
||||
const sdk = useSDK()
|
||||
|
||||
function update(sessionID: string, fn: (messages: SessionMessage[]) => void) {
|
||||
setStore(
|
||||
"messages",
|
||||
produce((draft) => {
|
||||
fn((draft[sessionID] ??= []))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
event.subscribe((event) => {
|
||||
switch (event.type) {
|
||||
case "session.next.prompted": {
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
draft.push({
|
||||
id: event.id,
|
||||
type: "user",
|
||||
text: event.properties.prompt.text,
|
||||
files: event.properties.prompt.files,
|
||||
agents: event.properties.prompt.agents,
|
||||
time: { created: event.properties.timestamp },
|
||||
})
|
||||
})
|
||||
break
|
||||
}
|
||||
case "session.next.synthetic":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
draft.push({
|
||||
id: event.id,
|
||||
type: "synthetic",
|
||||
sessionID: event.properties.sessionID,
|
||||
text: event.properties.text,
|
||||
time: { created: event.properties.timestamp },
|
||||
})
|
||||
})
|
||||
break
|
||||
case "session.next.shell.started":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
draft.push({
|
||||
id: event.id,
|
||||
type: "shell",
|
||||
callID: event.properties.callID,
|
||||
command: event.properties.command,
|
||||
output: "",
|
||||
time: { created: event.properties.timestamp },
|
||||
})
|
||||
})
|
||||
break
|
||||
case "session.next.shell.ended":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = activeShell(draft, event.properties.callID)
|
||||
if (!match) return
|
||||
match.output = event.properties.output
|
||||
match.time.completed = event.properties.timestamp
|
||||
})
|
||||
break
|
||||
case "session.next.step.started":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const currentAssistant = activeAssistant(draft)
|
||||
if (currentAssistant) currentAssistant.time.completed = event.properties.timestamp
|
||||
draft.push({
|
||||
id: event.id,
|
||||
type: "assistant",
|
||||
agent: event.properties.agent,
|
||||
model: event.properties.model,
|
||||
content: [],
|
||||
snapshot: event.properties.snapshot ? { start: event.properties.snapshot } : undefined,
|
||||
time: { created: event.properties.timestamp },
|
||||
})
|
||||
})
|
||||
break
|
||||
case "session.next.step.ended":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const currentAssistant = activeAssistant(draft)
|
||||
if (!currentAssistant) return
|
||||
currentAssistant.time.completed = event.properties.timestamp
|
||||
currentAssistant.finish = event.properties.finish
|
||||
currentAssistant.cost = event.properties.cost
|
||||
currentAssistant.tokens = event.properties.tokens
|
||||
if (event.properties.snapshot)
|
||||
currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.properties.snapshot }
|
||||
})
|
||||
break
|
||||
case "session.next.text.started":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
activeAssistant(draft)?.content.push({ type: "text", text: "" })
|
||||
})
|
||||
break
|
||||
case "session.next.text.delta":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestText(activeAssistant(draft))
|
||||
if (match) match.text += event.properties.delta
|
||||
})
|
||||
break
|
||||
case "session.next.text.ended":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestText(activeAssistant(draft))
|
||||
if (match) match.text = event.properties.text
|
||||
})
|
||||
break
|
||||
case "session.next.tool.input.started":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
activeAssistant(draft)?.content.push({
|
||||
type: "tool",
|
||||
id: event.properties.callID,
|
||||
name: event.properties.name,
|
||||
time: { created: event.properties.timestamp },
|
||||
state: { status: "pending", input: "" },
|
||||
})
|
||||
})
|
||||
break
|
||||
case "session.next.tool.input.delta":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestTool(activeAssistant(draft), event.properties.callID)
|
||||
if (match?.state.status === "pending") match.state.input += event.properties.delta
|
||||
})
|
||||
break
|
||||
case "session.next.tool.input.ended":
|
||||
break
|
||||
case "session.next.tool.called":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestTool(activeAssistant(draft), event.properties.callID)
|
||||
if (!match) return
|
||||
match.time.ran = event.properties.timestamp
|
||||
match.provider = event.properties.provider
|
||||
match.state = { status: "running", input: event.properties.input, structured: {}, content: [] }
|
||||
})
|
||||
break
|
||||
case "session.next.tool.progress":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestTool(activeAssistant(draft), event.properties.callID)
|
||||
if (match?.state.status !== "running") return
|
||||
match.state.structured = event.properties.structured
|
||||
match.state.content = [...event.properties.content]
|
||||
})
|
||||
break
|
||||
case "session.next.tool.success":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestTool(activeAssistant(draft), event.properties.callID)
|
||||
if (match?.state.status !== "running") return
|
||||
match.state = {
|
||||
status: "completed",
|
||||
input: match.state.input,
|
||||
structured: event.properties.structured,
|
||||
content: [...event.properties.content],
|
||||
}
|
||||
match.provider = event.properties.provider
|
||||
match.time.completed = event.properties.timestamp
|
||||
})
|
||||
break
|
||||
case "session.next.tool.error":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestTool(activeAssistant(draft), event.properties.callID)
|
||||
if (match?.state.status !== "running") return
|
||||
match.state = {
|
||||
status: "error",
|
||||
error: event.properties.error,
|
||||
input: match.state.input,
|
||||
structured: match.state.structured,
|
||||
content: match.state.content,
|
||||
}
|
||||
match.provider = event.properties.provider
|
||||
match.time.completed = event.properties.timestamp
|
||||
})
|
||||
break
|
||||
case "session.next.reasoning.started":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
activeAssistant(draft)?.content.push({
|
||||
type: "reasoning",
|
||||
id: event.properties.reasoningID,
|
||||
text: "",
|
||||
})
|
||||
})
|
||||
break
|
||||
case "session.next.reasoning.delta":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestReasoning(activeAssistant(draft), event.properties.reasoningID)
|
||||
if (match) match.text += event.properties.delta
|
||||
})
|
||||
break
|
||||
case "session.next.reasoning.ended":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestReasoning(activeAssistant(draft), event.properties.reasoningID)
|
||||
if (match) match.text = event.properties.text
|
||||
})
|
||||
break
|
||||
case "session.next.retried":
|
||||
break
|
||||
case "session.next.compaction.started":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
draft.push({
|
||||
id: event.id,
|
||||
type: "compaction",
|
||||
reason: event.properties.reason,
|
||||
summary: "",
|
||||
time: { created: event.properties.timestamp },
|
||||
})
|
||||
})
|
||||
break
|
||||
case "session.next.compaction.delta":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = activeCompaction(draft)
|
||||
if (match) match.summary += event.properties.text
|
||||
})
|
||||
break
|
||||
case "session.next.compaction.ended":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = activeCompaction(draft)
|
||||
if (!match) return
|
||||
match.summary = event.properties.text
|
||||
match.include = event.properties.include
|
||||
})
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
const result = {
|
||||
data: store,
|
||||
session: {
|
||||
message: {
|
||||
async sync(sessionID: string) {
|
||||
const response = await sdk.client.v2.session.messages({ sessionID })
|
||||
setStore("messages", sessionID, reconcile(response.data?.items ?? []))
|
||||
},
|
||||
fromSession(sessionID: string) {
|
||||
const messages = store.messages[sessionID]
|
||||
if (!messages) return []
|
||||
return messages
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
})
|
||||
@@ -110,7 +110,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const project = useProject()
|
||||
const sdk = useSDK()
|
||||
const kv = useKV()
|
||||
const [autoaccept] = kv.signal<"none" | "edit">("permission_auto_accept", "edit")
|
||||
|
||||
const fullSyncedSessions = new Set<string>()
|
||||
let syncedWorkspace = project.workspace.current()
|
||||
@@ -153,13 +152,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
|
||||
case "permission.asked": {
|
||||
const request = event.properties
|
||||
if (autoaccept() === "edit" && request.permission === "edit") {
|
||||
sdk.client.permission.reply({
|
||||
reply: "once",
|
||||
requestID: request.id,
|
||||
})
|
||||
break
|
||||
}
|
||||
const requests = store.permission[request.sessionID]
|
||||
if (!requests) {
|
||||
setStore("permission", request.sessionID, [request])
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,9 +7,7 @@ import SidebarTodo from "../feature-plugins/sidebar/todo"
|
||||
import SidebarFiles from "../feature-plugins/sidebar/files"
|
||||
import SidebarFooter from "../feature-plugins/sidebar/footer"
|
||||
import PluginManager from "../feature-plugins/system/plugins"
|
||||
import SessionV2Debug from "../feature-plugins/system/session-v2"
|
||||
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
|
||||
export type InternalTuiPlugin = TuiPluginModule & {
|
||||
id: string
|
||||
@@ -26,5 +24,4 @@ export const INTERNAL_TUI_PLUGINS: InternalTuiPlugin[] = [
|
||||
SidebarFiles,
|
||||
SidebarFooter,
|
||||
PluginManager,
|
||||
...(Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM ? [SessionV2Debug] : []),
|
||||
]
|
||||
|
||||
@@ -16,7 +16,7 @@ import { TuiConfig } from "@/cli/cmd/tui/config/tui"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { errorData, errorMessage } from "@/util/error"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { WithInstance } from "@/project/with-instance"
|
||||
import { Instance } from "@/project/instance"
|
||||
import {
|
||||
readPackageThemes,
|
||||
readPluginId,
|
||||
@@ -790,7 +790,7 @@ async function addPluginBySpec(state: RuntimeState | undefined, raw: string) {
|
||||
state.pending.delete(spec)
|
||||
return true
|
||||
}
|
||||
const ready = await WithInstance.provide({
|
||||
const ready = await Instance.provide({
|
||||
directory: state.directory,
|
||||
fn: () => resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies()),
|
||||
}).catch((error) => {
|
||||
@@ -986,7 +986,7 @@ async function load(input: { api: Api; config: TuiConfig.Info }) {
|
||||
}
|
||||
runtime = next
|
||||
try {
|
||||
await WithInstance.provide({
|
||||
await Instance.provide({
|
||||
directory: cwd,
|
||||
fn: async () => {
|
||||
const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? [])
|
||||
|
||||
@@ -37,8 +37,7 @@ import { Locale } from "@/util/locale"
|
||||
import type { Tool } from "@/tool/tool"
|
||||
import type { ReadTool } from "@/tool/read"
|
||||
import type { WriteTool } from "@/tool/write"
|
||||
import { ShellTool } from "@/tool/shell"
|
||||
import { ShellID } from "@/tool/shell/id"
|
||||
import { BashTool } from "@/tool/bash"
|
||||
import type { GlobTool } from "@/tool/glob"
|
||||
import { TodoWriteTool } from "@/tool/todo"
|
||||
import type { GrepTool } from "@/tool/grep"
|
||||
@@ -1553,8 +1552,8 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
|
||||
return (
|
||||
<Show when={!shouldHide()}>
|
||||
<Switch>
|
||||
<Match when={props.part.tool === ShellID.ToolID}>
|
||||
<Shell {...toolprops} />
|
||||
<Match when={props.part.tool === "bash"}>
|
||||
<Bash {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "glob"}>
|
||||
<Glob {...toolprops} />
|
||||
@@ -1785,7 +1784,7 @@ function BlockTool(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function Shell(props: ToolProps<typeof ShellTool>) {
|
||||
function Bash(props: ToolProps<typeof BashTool>) {
|
||||
const { theme } = useTheme()
|
||||
const sync = useSync()
|
||||
const isRunning = createMemo(() => props.part.state.status === "running")
|
||||
|
||||
@@ -15,7 +15,6 @@ import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
|
||||
import { Keybind } from "@/util/keybind"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { ShellID } from "@/tool/shell/id"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
import { useTuiConfig } from "../../context/tui-config"
|
||||
@@ -288,7 +287,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
|
||||
}
|
||||
}
|
||||
|
||||
if (permission === ShellID.ToolID) {
|
||||
if (permission === "bash") {
|
||||
const title =
|
||||
typeof data.description === "string" && data.description ? data.description : "Shell command"
|
||||
const command = typeof data.command === "string" ? data.command : ""
|
||||
|
||||
@@ -8,7 +8,6 @@ import { UI } from "@/cli/ui"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import { withTimeout } from "@/util/timeout"
|
||||
import { WithInstance } from "@/project/with-instance"
|
||||
import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
|
||||
@@ -191,11 +190,7 @@ export const TuiThreadCommand = cmd({
|
||||
const prompt = await input(args.prompt)
|
||||
const config = await TuiConfig.get()
|
||||
|
||||
const network = await WithInstance.provide({
|
||||
directory: cwd,
|
||||
fn: () => resolveNetworkOptionsNoConfig(args),
|
||||
})
|
||||
|
||||
const network = resolveNetworkOptionsNoConfig(args)
|
||||
const external =
|
||||
process.argv.includes("--port") ||
|
||||
process.argv.includes("--hostname") ||
|
||||
|
||||
@@ -37,7 +37,6 @@ export interface DialogSelectOption<T = any> {
|
||||
title: string
|
||||
value: T
|
||||
description?: string
|
||||
search?: string
|
||||
footer?: JSX.Element | string
|
||||
category?: string
|
||||
categoryView?: JSX.Element
|
||||
@@ -94,8 +93,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
// users typically search by the item name, and not its category.
|
||||
const result = fuzzysort
|
||||
.go(needle, options, {
|
||||
keys: ["title", "category", "search"],
|
||||
scoreFn: (r) => r[0].score * 2 + r[1].score + r[2].score,
|
||||
keys: ["title", "category"],
|
||||
scoreFn: (r) => r[0].score * 2 + r[1].score,
|
||||
})
|
||||
.map((x) => x.obj)
|
||||
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
import { Installation } from "@/installation"
|
||||
import { Server } from "@/server/server"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { InstanceRuntime } from "@/project/instance-runtime"
|
||||
import { WithInstance } from "@/project/with-instance"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { Rpc } from "@/util/rpc"
|
||||
import { upgrade } from "@/cli/upgrade"
|
||||
import { Config } from "@/config/config"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { ServerAuth } from "@/server/auth"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { writeHeapSnapshot } from "node:v8"
|
||||
import { Heap } from "@/cli/heap"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { AppRuntime, getBootstrapRunEffect } from "@/effect/app-runtime"
|
||||
import { ensureProcessMetadata } from "@opencode-ai/core/util/opencode-process"
|
||||
import { Effect } from "effect"
|
||||
import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle"
|
||||
|
||||
ensureProcessMetadata("worker")
|
||||
|
||||
@@ -50,7 +48,7 @@ let server: Awaited<ReturnType<typeof Server.listen>> | undefined
|
||||
export const rpc = {
|
||||
async fetch(input: { url: string; method: string; headers: Record<string, string>; body?: string }) {
|
||||
const headers = { ...input.headers }
|
||||
const auth = ServerAuth.header()
|
||||
const auth = getAuthorizationHeader()
|
||||
if (auth && !headers["authorization"] && !headers["Authorization"]) {
|
||||
headers["Authorization"] = auth
|
||||
}
|
||||
@@ -77,28 +75,30 @@ export const rpc = {
|
||||
return { url: server.url.toString() }
|
||||
},
|
||||
async checkUpgrade(input: { directory: string }) {
|
||||
await WithInstance.provide({
|
||||
await Instance.provide({
|
||||
directory: input.directory,
|
||||
init: await getBootstrapRunEffect(),
|
||||
fn: async () => {
|
||||
await upgrade().catch(() => {})
|
||||
},
|
||||
})
|
||||
},
|
||||
async reload() {
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const cfg = yield* Config.Service
|
||||
yield* cfg.invalidate()
|
||||
yield* disposeAllInstancesAndEmitGlobalDisposed({ swallowErrors: true })
|
||||
}),
|
||||
)
|
||||
await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.invalidate(true)))
|
||||
},
|
||||
async shutdown() {
|
||||
Log.Default.info("worker shutting down")
|
||||
|
||||
await InstanceRuntime.disposeAllInstances()
|
||||
await InstanceStore.disposeAllInstances()
|
||||
if (server) await server.stop(true)
|
||||
},
|
||||
}
|
||||
|
||||
Rpc.listen(rpc)
|
||||
|
||||
function getAuthorizationHeader(): string | undefined {
|
||||
const password = Flag.OPENCODE_SERVER_PASSWORD
|
||||
if (!password) return undefined
|
||||
const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
|
||||
return `Basic ${btoa(`${username}:${password}`)}`
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Effect } from "effect"
|
||||
import { Server } from "../../server/server"
|
||||
import { UI } from "../ui"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
import { cmd } from "./cmd"
|
||||
import { withNetworkOptions, resolveNetworkOptions } from "../network"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import open from "open"
|
||||
@@ -29,19 +28,16 @@ function getNetworkIPs() {
|
||||
return results
|
||||
}
|
||||
|
||||
export const WebCommand = effectCmd({
|
||||
export const WebCommand = cmd({
|
||||
command: "web",
|
||||
builder: (yargs) => withNetworkOptions(yargs),
|
||||
describe: "start opencode server and open web interface",
|
||||
// Server loads instances per-request via x-opencode-directory header — no
|
||||
// ambient project InstanceContext needed at startup.
|
||||
instance: false,
|
||||
handler: Effect.fn("Cli.web")(function* (args) {
|
||||
handler: async (args) => {
|
||||
if (!Flag.OPENCODE_SERVER_PASSWORD) {
|
||||
UI.println(UI.Style.TEXT_WARNING_BOLD + "! OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
|
||||
}
|
||||
const opts = yield* Effect.promise(() => resolveNetworkOptions(args))
|
||||
const server = yield* Effect.promise(() => Server.listen(opts))
|
||||
const opts = await resolveNetworkOptions(args)
|
||||
const server = await Server.listen(opts)
|
||||
UI.empty()
|
||||
UI.println(UI.logo(" "))
|
||||
UI.empty()
|
||||
@@ -79,6 +75,7 @@ export const WebCommand = effectCmd({
|
||||
open(displayUrl).catch(() => {})
|
||||
}
|
||||
|
||||
yield* Effect.never
|
||||
}),
|
||||
await new Promise(() => {})
|
||||
await server.stop()
|
||||
},
|
||||
})
|
||||
|
||||
@@ -2,9 +2,7 @@ import type { Argv } from "yargs"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { AppRuntime, type AppServices } from "@/effect/app-runtime"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { cmd, type WithDoubleDash } from "./cmd/cmd"
|
||||
import { cmd } from "./cmd/cmd"
|
||||
|
||||
/**
|
||||
* User-visible command failure. Throw via `fail("...")` from an effectCmd handler
|
||||
@@ -19,47 +17,10 @@ export class CliError extends Schema.TaggedErrorClass<CliError>()("CliError", {
|
||||
|
||||
export const fail = (message: string, exitCode = 1) => Effect.fail(new CliError({ message, exitCode }))
|
||||
|
||||
interface EffectCmdOpts<Args, A> {
|
||||
command: string | readonly string[]
|
||||
aliases?: string | readonly string[]
|
||||
describe: string | false
|
||||
builder?: (yargs: Argv) => Argv<Args>
|
||||
/**
|
||||
* Whether the command needs a project InstanceContext. Defaults to true.
|
||||
*
|
||||
* `true` (default): wraps the handler in `InstanceStore.Service.provide({directory})`
|
||||
* so `InstanceRef` resolves to a loaded `InstanceContext`. Auto-disposes via
|
||||
* `Effect.ensuring(store.dispose(ctx))` on every Exit (matches the legacy
|
||||
* `bootstrap()` finally-disposal). Runs InstanceBootstrap (config + plugin
|
||||
* init + LSP/File/etc forks) eagerly.
|
||||
*
|
||||
* `false`: skip the instance entirely. Saves the InstanceBootstrap work and
|
||||
* suppresses the `server.instance.disposed` IPC event. The handler runs
|
||||
* directly under AppRuntime — it can yield any `AppServices` but must not
|
||||
* yield `InstanceRef` (it'd be undefined, causing a defect).
|
||||
*
|
||||
* Function form: `(args) => boolean` decides per-invocation. Useful for
|
||||
* commands like `run --attach <url>` where one flag flips between local
|
||||
* (needs instance) and remote (doesn't).
|
||||
*
|
||||
* Use `false` for commands that don't read project state (e.g. `models`,
|
||||
* `serve`, `web`, `account`, `db`, `upgrade`).
|
||||
*/
|
||||
instance?: boolean | ((args: Args) => boolean)
|
||||
/** Defaults to process.cwd(). Override for commands that take a directory positional. */
|
||||
directory?: (args: Args) => string
|
||||
handler: (args: WithDoubleDash<Args>) => Effect.Effect<A, CliError, AppServices | InstanceStore.Service>
|
||||
}
|
||||
|
||||
/**
|
||||
* Effect-native CLI command builder. Wraps yargs `cmd()` so the handler body is
|
||||
* an `Effect` with `InstanceRef` provided and any `AppServices` yieldable.
|
||||
*
|
||||
* The handler is wrapped in `Effect.ensuring(store.dispose(ctx))` so the loaded
|
||||
* InstanceContext is disposed (runDisposers + IPC `server.instance.disposed`)
|
||||
* on every Exit — success, typed failure, defect, or interruption. Matches the
|
||||
* legacy `bootstrap()` finally-disposal semantics without per-handler boilerplate.
|
||||
*
|
||||
* Errors propagate to the existing top-level handler in `src/index.ts`; use
|
||||
* `fail("...")` for user-visible domain failures (clean exit, formatted message).
|
||||
*
|
||||
@@ -68,36 +29,22 @@ interface EffectCmdOpts<Args, A> {
|
||||
* `effectCmd`, swapping the underlying `cmd()` factory for effect/cli's
|
||||
* `Command.make(...)` won't touch any handler bodies.
|
||||
*/
|
||||
export const effectCmd = <Args, A>(opts: EffectCmdOpts<Args, A>) =>
|
||||
export const effectCmd = <Args, A>(opts: {
|
||||
command: string | readonly string[]
|
||||
describe: string | false
|
||||
builder?: (yargs: Argv) => Argv<Args>
|
||||
/** Defaults to process.cwd(). Override for commands that take a directory positional. */
|
||||
directory?: (args: Args) => string
|
||||
handler: (args: Args) => Effect.Effect<A, CliError, AppServices | InstanceStore.Service>
|
||||
}) =>
|
||||
cmd<{}, Args>({
|
||||
command: opts.command,
|
||||
aliases: opts.aliases,
|
||||
describe: opts.describe,
|
||||
builder: opts.builder as never,
|
||||
async handler(rawArgs) {
|
||||
// yargs typing wraps Args in ArgumentsCamelCase<WithDoubleDash<...>>; cast at the boundary.
|
||||
const args = rawArgs as unknown as WithDoubleDash<Args>
|
||||
const useInstance = typeof opts.instance === "function" ? opts.instance(args) : opts.instance !== false
|
||||
if (!useInstance) {
|
||||
await AppRuntime.runPromise(opts.handler(args))
|
||||
return
|
||||
}
|
||||
const args = rawArgs as unknown as Args
|
||||
const directory = opts.directory?.(args) ?? process.cwd()
|
||||
// Two-phase: load ctx, then run body inside Instance.current ALS.
|
||||
// Effect's InstanceRef is provided via fiber context, but that context is
|
||||
// lost across `await` inside `Effect.promise(async () => ...)` callbacks
|
||||
// — when handlers re-enter Effect via `AppRuntime.runPromise(svc.method())`
|
||||
// there, attach() falls back to Instance.current ALS, which Node preserves
|
||||
// across awaits. Matches the pre-effectCmd `bootstrap()` behavior.
|
||||
const { store, ctx } = await AppRuntime.runPromise(
|
||||
InstanceStore.Service.use((store) => store.load({ directory }).pipe(Effect.map((ctx) => ({ store, ctx })))),
|
||||
)
|
||||
try {
|
||||
await Instance.restore(ctx, () =>
|
||||
AppRuntime.runPromise(opts.handler(args).pipe(Effect.provideService(InstanceRef, ctx))),
|
||||
)
|
||||
} finally {
|
||||
await AppRuntime.runPromise(store.dispose(ctx))
|
||||
}
|
||||
await AppRuntime.runPromise(InstanceStore.Service.use((s) => s.provide({ directory }, opts.handler(args))))
|
||||
},
|
||||
})
|
||||
|
||||
@@ -12,8 +12,11 @@ import { Auth } from "../auth"
|
||||
import { Env } from "../env"
|
||||
import { applyEdits, modify } from "jsonc-parser"
|
||||
import { type InstanceContext } from "../project/instance"
|
||||
import { InstanceStore } from "../project/instance-store"
|
||||
import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { existsSync } from "fs"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Event } from "../server/event"
|
||||
import { Account } from "@/account/account"
|
||||
import { isRecord } from "@/util/record"
|
||||
import type { ConsoleState } from "./console-state"
|
||||
@@ -192,14 +195,8 @@ export const Info = Schema.Struct({
|
||||
]),
|
||||
),
|
||||
).annotate({ description: "MCP (Model Context Protocol) server configurations" }),
|
||||
formatter: Schema.optional(ConfigFormatter.Info).annotate({
|
||||
description:
|
||||
"Enable or configure formatters. Omit or set to false to disable, true to enable built-ins, or an object to enable built-ins with overrides.",
|
||||
}),
|
||||
lsp: Schema.optional(ConfigLSP.Info).annotate({
|
||||
description:
|
||||
"Enable or configure LSP servers. Omit or set to false to disable, true to enable built-ins, or an object to enable built-ins with overrides.",
|
||||
}),
|
||||
formatter: Schema.optional(ConfigFormatter.Info),
|
||||
lsp: Schema.optional(ConfigLSP.Info),
|
||||
instructions: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({
|
||||
description: "Additional instruction files or patterns to include",
|
||||
}),
|
||||
@@ -292,9 +289,9 @@ export interface Interface {
|
||||
readonly get: () => Effect.Effect<Info>
|
||||
readonly getGlobal: () => Effect.Effect<Info>
|
||||
readonly getConsoleState: () => Effect.Effect<ConsoleState>
|
||||
readonly update: (config: Info) => Effect.Effect<void>
|
||||
readonly updateGlobal: (config: Info) => Effect.Effect<{ info: Info; changed: boolean }>
|
||||
readonly invalidate: () => Effect.Effect<void>
|
||||
readonly update: (config: Info, options?: { dispose?: boolean }) => Effect.Effect<void>
|
||||
readonly updateGlobal: (config: Info) => Effect.Effect<Info>
|
||||
readonly invalidate: (wait?: boolean) => Effect.Effect<void>
|
||||
readonly directories: () => Effect.Effect<string[]>
|
||||
readonly waitForDependencies: () => Effect.Effect<void>
|
||||
}
|
||||
@@ -355,7 +352,15 @@ export const layer = Layer.effect(
|
||||
const env = yield* Env.Service
|
||||
const npmSvc = yield* Npm.Service
|
||||
|
||||
const readConfigFile = (filepath: string) => fs.readFileStringSafe(filepath).pipe(Effect.orDie)
|
||||
const readConfigFile = Effect.fnUntraced(function* (filepath: string) {
|
||||
return yield* fs.readFileString(filepath).pipe(
|
||||
Effect.catchIf(
|
||||
(e) => e.reason._tag === "NotFound",
|
||||
() => Effect.succeed(undefined),
|
||||
),
|
||||
Effect.orDie,
|
||||
)
|
||||
})
|
||||
|
||||
const loadConfig = Effect.fnUntraced(function* (
|
||||
text: string,
|
||||
@@ -725,17 +730,37 @@ export const layer = Layer.effect(
|
||||
)
|
||||
})
|
||||
|
||||
const update = Effect.fn("Config.update")(function* (config: Info) {
|
||||
const update = Effect.fn("Config.update")(function* (config: Info, options?: { dispose?: boolean }) {
|
||||
const dir = yield* InstanceState.directory
|
||||
const file = path.join(dir, "config.json")
|
||||
const existing = yield* loadFile(file)
|
||||
yield* fs
|
||||
.writeFileString(file, JSON.stringify(mergeDeep(writable(existing), writable(config)), null, 2))
|
||||
.pipe(Effect.orDie)
|
||||
if (options?.dispose !== false) {
|
||||
// Fail loudly if no instance is bound — silently skipping would
|
||||
// mask "config update without an active instance" bugs. The throw
|
||||
// comes from `Instance.current` inside `InstanceState.context`.
|
||||
const ctx = yield* InstanceState.context
|
||||
yield* Effect.promise(() => InstanceStore.disposeInstance(ctx))
|
||||
}
|
||||
})
|
||||
|
||||
const invalidate = Effect.fn("Config.invalidate")(function* () {
|
||||
const invalidate = Effect.fn("Config.invalidate")(function* (wait?: boolean) {
|
||||
yield* invalidateGlobal
|
||||
const task = InstanceStore.disposeAllInstances()
|
||||
.catch(() => undefined)
|
||||
.finally(() =>
|
||||
GlobalBus.emit("event", {
|
||||
directory: "global",
|
||||
payload: {
|
||||
type: Event.Disposed.type,
|
||||
properties: {},
|
||||
},
|
||||
}),
|
||||
)
|
||||
if (wait) yield* Effect.promise(() => task)
|
||||
else void task
|
||||
})
|
||||
|
||||
const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) {
|
||||
@@ -759,8 +784,9 @@ export const layer = Layer.effect(
|
||||
if (changed) yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
|
||||
}
|
||||
|
||||
// Only tear down running instances if the config actually changed.
|
||||
if (changed) yield* invalidate()
|
||||
return { info: next, changed }
|
||||
return next
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
export * as ConfigPaths from "./paths"
|
||||
|
||||
import path from "path"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { unique } from "remeda"
|
||||
import { JsonError } from "./error"
|
||||
import * as Effect from "effect/Effect"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
|
||||
@@ -43,3 +45,11 @@ export const directories = Effect.fn("ConfigPaths.directories")(function* (direc
|
||||
export function fileInDirectory(dir: string, name: string) {
|
||||
return [path.join(dir, `${name}.json`), path.join(dir, `${name}.jsonc`)]
|
||||
}
|
||||
|
||||
/** Read a config file, returning undefined for missing files and throwing JsonError for other failures. */
|
||||
export async function readFile(filepath: string) {
|
||||
return Filesystem.readText(filepath).catch((err: NodeJS.ErrnoException) => {
|
||||
if (err.code === "ENOENT") return
|
||||
throw new JsonError({ path: filepath }, { cause: err })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Layer, ManagedRuntime } from "effect"
|
||||
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||
import { attach } from "./run-service"
|
||||
import * as Observability from "@opencode-ai/core/effect/observability"
|
||||
|
||||
@@ -40,44 +40,27 @@ import { Command } from "@/command"
|
||||
import { Truncate } from "@/tool/truncate"
|
||||
import { ToolRegistry } from "@/tool/registry"
|
||||
import { Format } from "@/format"
|
||||
import { InstanceLayer } from "@/project/instance-layer"
|
||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { Project } from "@/project/project"
|
||||
import { Vcs } from "@/project/vcs"
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
import { Worktree } from "@/worktree"
|
||||
import { Pty } from "@/pty"
|
||||
import { Installation } from "@/installation"
|
||||
import * as Effect from "effect/Effect"
|
||||
import { ShareNext } from "@/share/share-next"
|
||||
import { SessionShare } from "@/share/session"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
import { memoMap } from "@opencode-ai/core/effect/memo-map"
|
||||
|
||||
// Adjusts the default Config layer to ensure that plugins are always initialised before
|
||||
// any other layers read the current config
|
||||
const ConfigWithPluginPriority = Layer.effect(
|
||||
Config.Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const plugin = yield* Plugin.Service
|
||||
|
||||
return {
|
||||
...config,
|
||||
get: () => Effect.andThen(plugin.init(), config.get),
|
||||
getGlobal: () => Effect.andThen(plugin.init(), config.getGlobal),
|
||||
getConsoleState: () => Effect.andThen(plugin.init(), config.getConsoleState),
|
||||
}
|
||||
}),
|
||||
).pipe(Layer.provide(Layer.merge(Plugin.defaultLayer, Config.defaultLayer)))
|
||||
|
||||
export const AppLayer = Layer.mergeAll(
|
||||
Npm.defaultLayer,
|
||||
AppFileSystem.defaultLayer,
|
||||
Bus.defaultLayer,
|
||||
Auth.defaultLayer,
|
||||
Account.defaultLayer,
|
||||
ConfigWithPluginPriority,
|
||||
Config.defaultLayer,
|
||||
Git.defaultLayer,
|
||||
Ripgrep.defaultLayer,
|
||||
File.defaultLayer,
|
||||
@@ -111,16 +94,18 @@ export const AppLayer = Layer.mergeAll(
|
||||
Truncate.defaultLayer,
|
||||
ToolRegistry.defaultLayer,
|
||||
Format.defaultLayer,
|
||||
InstanceBootstrap.defaultLayer,
|
||||
InstanceStore.defaultLayer,
|
||||
Project.defaultLayer,
|
||||
Vcs.defaultLayer,
|
||||
Workspace.defaultLayer,
|
||||
Worktree.appLayer,
|
||||
Worktree.defaultLayer,
|
||||
Pty.defaultLayer,
|
||||
Installation.defaultLayer,
|
||||
ShareNext.defaultLayer,
|
||||
SessionShare.defaultLayer,
|
||||
SyncEvent.defaultLayer,
|
||||
).pipe(Layer.provideMerge(InstanceLayer.layer), Layer.provideMerge(Observability.layer))
|
||||
).pipe(Layer.provideMerge(Observability.layer))
|
||||
|
||||
const rt = ManagedRuntime.make(AppLayer, { memoMap })
|
||||
type Runtime = Pick<typeof rt, "runSync" | "runPromise" | "runPromiseExit" | "runFork" | "runCallback" | "dispose">
|
||||
@@ -147,3 +132,15 @@ export const AppRuntime: Runtime = {
|
||||
},
|
||||
dispose: () => rt.dispose(),
|
||||
}
|
||||
|
||||
let bootstrapRun: Promise<Effect.Effect<void>>
|
||||
export function getBootstrapRunEffect(): Promise<Effect.Effect<void>> {
|
||||
if (!bootstrapRun) {
|
||||
bootstrapRun = AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
return (yield* InstanceBootstrap.Service).run
|
||||
}),
|
||||
)
|
||||
}
|
||||
return bootstrapRun
|
||||
}
|
||||
|
||||
@@ -123,9 +123,7 @@ export const layer = Layer.effect(
|
||||
const cfgIgnores = cfg.watcher?.ignore ?? []
|
||||
|
||||
if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
|
||||
yield* Effect.forkScoped(
|
||||
subscribe(ctx.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...protecteds(ctx.directory)]),
|
||||
)
|
||||
yield* subscribe(ctx.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...protecteds(ctx.directory)])
|
||||
}
|
||||
|
||||
if (ctx.project.vcs === "git") {
|
||||
@@ -137,7 +135,7 @@ export const layer = Layer.effect(
|
||||
const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter(
|
||||
(entry) => entry !== "HEAD",
|
||||
)
|
||||
yield* Effect.forkScoped(subscribe(vcsDir, ignore))
|
||||
yield* subscribe(vcsDir, ignore)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -24,7 +24,6 @@ const fail = (err: unknown) =>
|
||||
text: () => "",
|
||||
stdout: Buffer.alloc(0),
|
||||
stderr: Buffer.from(err instanceof Error ? err.message : String(err)),
|
||||
truncated: false,
|
||||
}) satisfies Result
|
||||
|
||||
export type Kind = "added" | "deleted" | "modified"
|
||||
@@ -46,28 +45,16 @@ export type Stat = {
|
||||
readonly deletions: number
|
||||
}
|
||||
|
||||
export type Patch = {
|
||||
readonly text: string
|
||||
readonly truncated: boolean
|
||||
}
|
||||
|
||||
export interface PatchOptions {
|
||||
readonly context?: number
|
||||
readonly maxOutputBytes?: number
|
||||
}
|
||||
|
||||
export interface Result {
|
||||
readonly exitCode: number
|
||||
readonly text: () => string
|
||||
readonly stdout: Buffer
|
||||
readonly stderr: Buffer
|
||||
readonly truncated: boolean
|
||||
}
|
||||
|
||||
export interface Options {
|
||||
readonly cwd: string
|
||||
readonly env?: Record<string, string>
|
||||
readonly maxOutputBytes?: number
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
@@ -81,10 +68,6 @@ export interface Interface {
|
||||
readonly status: (cwd: string) => Effect.Effect<Item[]>
|
||||
readonly diff: (cwd: string, ref: string) => Effect.Effect<Item[]>
|
||||
readonly stats: (cwd: string, ref: string) => Effect.Effect<Stat[]>
|
||||
readonly patch: (cwd: string, ref: string, file: string, options?: PatchOptions) => Effect.Effect<Patch>
|
||||
readonly patchAll: (cwd: string, ref: string, options?: PatchOptions) => Effect.Effect<Patch>
|
||||
readonly patchUntracked: (cwd: string, file: string, options?: PatchOptions) => Effect.Effect<Patch>
|
||||
readonly statUntracked: (cwd: string, file: string) => Effect.Effect<Stat | undefined>
|
||||
}
|
||||
|
||||
const kind = (code: string): Kind => {
|
||||
@@ -113,31 +96,15 @@ export const layer = Layer.effect(
|
||||
stderr: "pipe",
|
||||
})
|
||||
const handle = yield* spawner.spawn(proc)
|
||||
const collect = (stream: typeof handle.stdout) =>
|
||||
Stream.runFold(
|
||||
stream,
|
||||
() => ({ chunks: [] as Uint8Array[], bytes: 0, truncated: false }),
|
||||
(acc, chunk) => {
|
||||
if (opts.maxOutputBytes === undefined) {
|
||||
acc.chunks.push(chunk)
|
||||
acc.bytes += chunk.length
|
||||
return acc
|
||||
}
|
||||
|
||||
const remaining = opts.maxOutputBytes - acc.bytes
|
||||
if (remaining > 0) acc.chunks.push(remaining >= chunk.length ? chunk : chunk.slice(0, remaining))
|
||||
acc.bytes += chunk.length
|
||||
acc.truncated = acc.truncated || acc.bytes > opts.maxOutputBytes
|
||||
return acc
|
||||
},
|
||||
).pipe(Effect.map((x) => ({ buffer: Buffer.concat(x.chunks), truncated: x.truncated })))
|
||||
const [stdout, stderr] = yield* Effect.all([collect(handle.stdout), collect(handle.stderr)], { concurrency: 2 })
|
||||
const [stdout, stderr] = yield* Effect.all(
|
||||
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
return {
|
||||
exitCode: yield* handle.exitCode,
|
||||
text: () => stdout.buffer.toString("utf8"),
|
||||
stdout: stdout.buffer,
|
||||
stderr: stderr.buffer,
|
||||
truncated: stdout.truncated || stderr.truncated,
|
||||
text: () => stdout,
|
||||
stdout: Buffer.from(stdout),
|
||||
stderr: Buffer.from(stderr),
|
||||
} satisfies Result
|
||||
},
|
||||
Effect.scoped,
|
||||
@@ -273,61 +240,6 @@ export const layer = Layer.effect(
|
||||
})
|
||||
})
|
||||
|
||||
const patch = Effect.fn("Git.patch")(function* (cwd: string, ref: string, file: string, options?: PatchOptions) {
|
||||
const result = yield* run(
|
||||
["diff", "--patch", "--no-ext-diff", "--no-renames", `--unified=${options?.context ?? 3}`, ref, "--", file],
|
||||
{ cwd, maxOutputBytes: options?.maxOutputBytes },
|
||||
)
|
||||
return { text: result.truncated ? "" : result.text(), truncated: result.truncated } satisfies Patch
|
||||
})
|
||||
|
||||
const patchAll = Effect.fn("Git.patchAll")(function* (cwd: string, ref: string, options?: PatchOptions) {
|
||||
const result = yield* run(
|
||||
["diff", "--patch", "--no-ext-diff", "--no-renames", `--unified=${options?.context ?? 3}`, ref, "--", "."],
|
||||
{ cwd, maxOutputBytes: options?.maxOutputBytes },
|
||||
)
|
||||
return { text: result.text(), truncated: result.truncated } satisfies Patch
|
||||
})
|
||||
|
||||
const patchUntracked = Effect.fn("Git.patchUntracked")(function* (
|
||||
cwd: string,
|
||||
file: string,
|
||||
options?: PatchOptions,
|
||||
) {
|
||||
const result = yield* run(
|
||||
[
|
||||
"diff",
|
||||
"--no-index",
|
||||
"--patch",
|
||||
"--no-ext-diff",
|
||||
"--no-renames",
|
||||
`--unified=${options?.context ?? 3}`,
|
||||
"--",
|
||||
"/dev/null",
|
||||
file,
|
||||
],
|
||||
{ cwd, maxOutputBytes: options?.maxOutputBytes },
|
||||
)
|
||||
return { text: result.truncated ? "" : result.text(), truncated: result.truncated } satisfies Patch
|
||||
})
|
||||
|
||||
const statUntracked = Effect.fn("Git.statUntracked")(function* (cwd: string, file: string) {
|
||||
const result = yield* run(["diff", "--no-index", "--numstat", "--", "/dev/null", file], {
|
||||
cwd,
|
||||
maxOutputBytes: 4096,
|
||||
})
|
||||
if (result.truncated) return
|
||||
const parts = result.text().split("\t")
|
||||
if (parts.length < 2) return
|
||||
const additions = parts[0] === "-" ? 0 : Number.parseInt(parts[0] || "0", 10)
|
||||
const deletions = parts[1] === "-" ? 0 : Number.parseInt(parts[1] || "0", 10)
|
||||
return {
|
||||
file,
|
||||
additions: Number.isFinite(additions) ? additions : 0,
|
||||
deletions: Number.isFinite(deletions) ? deletions : 0,
|
||||
} satisfies Stat
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
run,
|
||||
branch,
|
||||
@@ -339,10 +251,6 @@ export const layer = Layer.effect(
|
||||
status,
|
||||
diff,
|
||||
stats,
|
||||
patch,
|
||||
patchAll,
|
||||
patchUntracked,
|
||||
statUntracked,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -144,6 +144,7 @@ interface State {
|
||||
}
|
||||
|
||||
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
|
||||
log.info("evaluate", { permission, pattern, ruleset: rulesets.flat() })
|
||||
return evalRule(permission, pattern, ...rulesets)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import { Bus } from "../bus"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { ServerAuth } from "@/server/auth"
|
||||
import { CodexAuthPlugin } from "./codex"
|
||||
import { Session } from "@/session/session"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
@@ -125,7 +124,11 @@ export const layer = Layer.effect(
|
||||
const client = createOpencodeClient({
|
||||
baseUrl: "http://localhost:4096",
|
||||
directory: ctx.directory,
|
||||
headers: ServerAuth.headers(),
|
||||
headers: Flag.OPENCODE_SERVER_PASSWORD
|
||||
? {
|
||||
Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`,
|
||||
}
|
||||
: undefined,
|
||||
fetch: async (...args) => Server.Default().app.fetch(...args),
|
||||
})
|
||||
const cfg = yield* config.get()
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Context, Effect } from "effect"
|
||||
|
||||
export interface Interface {
|
||||
readonly run: Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/InstanceBootstrap") {}
|
||||
|
||||
export * as InstanceBootstrap from "./bootstrap-service"
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Plugin } from "../plugin"
|
||||
import { Format } from "../format"
|
||||
import { LSP } from "@/lsp/lsp"
|
||||
import { File } from "../file"
|
||||
@@ -5,29 +6,32 @@ import { Snapshot } from "../snapshot"
|
||||
import * as Project from "./project"
|
||||
import * as Vcs from "./vcs"
|
||||
import { Bus } from "../bus"
|
||||
import { Plugin } from "../plugin"
|
||||
import { Command } from "../command"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
import { ShareNext } from "@/share/share-next"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Config } from "@/config/config"
|
||||
import { Service } from "./bootstrap-service"
|
||||
|
||||
export { Service } from "./bootstrap-service"
|
||||
export type { Interface } from "./bootstrap-service"
|
||||
export interface Interface {
|
||||
readonly run: Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/InstanceBootstrap") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
// Yield each bootstrap dep at layer init so `run` itself has R = never.
|
||||
// InstanceStore imports only the lightweight tag from bootstrap-service.ts,
|
||||
// so it can depend on bootstrap without importing this implementation graph.
|
||||
// This breaks the circular declaration loop through Config → Instance → InstanceStore
|
||||
// (instance-store.ts only yields this Service tag, never the impl-side services).
|
||||
const bus = yield* Bus.Service
|
||||
const config = yield* Config.Service
|
||||
const file = yield* File.Service
|
||||
const fileWatcher = yield* FileWatcher.Service
|
||||
const format = yield* Format.Service
|
||||
const lsp = yield* LSP.Service
|
||||
const project = yield* Project.Service
|
||||
const plugin = yield* Plugin.Service
|
||||
const shareNext = yield* ShareNext.Service
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const vcs = yield* Vcs.Service
|
||||
@@ -35,16 +39,20 @@ export const layer = Layer.effect(
|
||||
const run = Effect.gen(function* () {
|
||||
const ctx = yield* InstanceState.context
|
||||
yield* Effect.logInfo("bootstrapping", { directory: ctx.directory })
|
||||
// everything depends on config so eager load it for nice traces.
|
||||
// Config can initialise plugins through its layer override.
|
||||
// everything depends on config so eager load it for nice traces
|
||||
yield* config.get()
|
||||
// Each service self-manages its own slow work via Effect.forkScoped against
|
||||
// its per-instance state scope. We just await materialization here.
|
||||
yield* Effect.forEach(
|
||||
[lsp, shareNext, format, file, fileWatcher, vcs, snapshot, project],
|
||||
(s) => s.init().pipe(Effect.catchCause((cause) => Effect.logWarning("init failed", { cause }))),
|
||||
{ concurrency: "unbounded", discard: true },
|
||||
// Plugin can mutate config so it has to be initialized before anything else.
|
||||
yield* plugin.init()
|
||||
yield* Effect.all(
|
||||
[lsp, shareNext, format, file, fileWatcher, vcs, snapshot].map((s) => Effect.forkDetach(s.init())),
|
||||
).pipe(Effect.withSpan("InstanceBootstrap.init"))
|
||||
|
||||
const projectID = ctx.project.id
|
||||
yield* bus.subscribeCallback(Command.Event.Executed, async (payload) => {
|
||||
if (payload.properties.name === Command.Default.INIT) {
|
||||
Project.setInitialized(projectID)
|
||||
}
|
||||
})
|
||||
}).pipe(Effect.withSpan("InstanceBootstrap"))
|
||||
|
||||
return Service.of({ run })
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Effect, Layer } from "effect"
|
||||
import { InstanceStore } from "./instance-store"
|
||||
|
||||
export const layer = Layer.unwrap(
|
||||
Effect.promise(async () => {
|
||||
const { InstanceBootstrap } = await import("./bootstrap")
|
||||
return InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer))
|
||||
}),
|
||||
)
|
||||
|
||||
export * as InstanceLayer from "./instance-layer"
|
||||
@@ -1,16 +0,0 @@
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { type InstanceContext } from "./instance-context"
|
||||
import { InstanceStore, type LoadInput } from "./instance-store"
|
||||
|
||||
// Bridge for Promise/ALS callers that cannot yet yield InstanceStore.Service.
|
||||
// Delete this module once those callers are migrated to Effect boundaries that
|
||||
// provide InstanceStore directly.
|
||||
|
||||
export const load = (input: LoadInput) => AppRuntime.runPromise(InstanceStore.Service.use((store) => store.load(input)))
|
||||
export const disposeInstance = (ctx: InstanceContext) =>
|
||||
AppRuntime.runPromise(InstanceStore.Service.use((store) => store.dispose(ctx)))
|
||||
export const disposeAllInstances = () => AppRuntime.runPromise(InstanceStore.Service.use((store) => store.disposeAll()))
|
||||
export const reloadInstance = (input: LoadInput) =>
|
||||
AppRuntime.runPromise(InstanceStore.Service.use((store) => store.reload(input)))
|
||||
|
||||
export * as InstanceRuntime from "./instance-runtime"
|
||||
@@ -2,24 +2,32 @@ import { GlobalBus } from "@/bus/global"
|
||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { disposeInstance as runDisposers } from "@/effect/instance-registry"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Context, Deferred, Duration, Effect, Exit, Layer, Scope } from "effect"
|
||||
import { type InstanceContext } from "./instance-context"
|
||||
import { InstanceBootstrap } from "./bootstrap-service"
|
||||
import * as Project from "./project"
|
||||
|
||||
export interface LoadInput {
|
||||
export interface LoadInput<R = never> {
|
||||
directory: string
|
||||
/**
|
||||
* Additional setup to run after the default InstanceBootstrap.
|
||||
* Mainly used by tests for env-var setup or file writes that need the instance ALS context.
|
||||
*/
|
||||
init?: Effect.Effect<void, never, R>
|
||||
worktree?: string
|
||||
project?: Project.Info
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly load: (input: LoadInput) => Effect.Effect<InstanceContext>
|
||||
readonly reload: (input: LoadInput) => Effect.Effect<InstanceContext>
|
||||
readonly load: <R = never>(input: LoadInput<R>) => Effect.Effect<InstanceContext, never, R>
|
||||
readonly reload: <R = never>(input: LoadInput<R>) => Effect.Effect<InstanceContext, never, R>
|
||||
readonly dispose: (ctx: InstanceContext) => Effect.Effect<void>
|
||||
readonly disposeAll: () => Effect.Effect<void>
|
||||
readonly provide: <A, E, R>(input: LoadInput, effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
|
||||
readonly provide: <A, E, R, R2 = never>(
|
||||
input: LoadInput<R2>,
|
||||
effect: Effect.Effect<A, E, R>,
|
||||
) => Effect.Effect<A, E, R | R2>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/InstanceStore") {}
|
||||
@@ -28,15 +36,14 @@ interface Entry {
|
||||
readonly deferred: Deferred.Deferred<InstanceContext>
|
||||
}
|
||||
|
||||
export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootstrap.Service> = Layer.effect(
|
||||
export const layer: Layer.Layer<Service, never, Project.Service> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const project = yield* Project.Service
|
||||
const bootstrap = yield* InstanceBootstrap.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const cache = new Map<string, Entry>()
|
||||
|
||||
const boot = (input: LoadInput & { directory: string }) =>
|
||||
const boot = <R>(input: LoadInput<R> & { directory: string }) =>
|
||||
Effect.gen(function* () {
|
||||
const ctx: InstanceContext =
|
||||
input.project && input.worktree
|
||||
@@ -52,7 +59,7 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
|
||||
project: result.project,
|
||||
})),
|
||||
)
|
||||
yield* bootstrap.run.pipe(Effect.provideService(InstanceRef, ctx))
|
||||
if (input.init) yield* input.init.pipe(Effect.provideService(InstanceRef, ctx))
|
||||
return ctx
|
||||
}).pipe(Effect.withSpan("InstanceStore.boot"))
|
||||
|
||||
@@ -63,7 +70,7 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
|
||||
return true
|
||||
})
|
||||
|
||||
const completeLoad = (directory: string, input: LoadInput, entry: Entry) =>
|
||||
const completeLoad = <R>(directory: string, input: LoadInput<R>, entry: Entry) =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* Effect.exit(boot({ ...input, directory }))
|
||||
if (Exit.isFailure(exit)) yield* removeEntry(directory, entry)
|
||||
@@ -99,7 +106,7 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
|
||||
return true
|
||||
})
|
||||
|
||||
const load = (input: LoadInput): Effect.Effect<InstanceContext> => {
|
||||
const load = <R>(input: LoadInput<R>): Effect.Effect<InstanceContext, never, R> => {
|
||||
const directory = AppFileSystem.resolve(input.directory)
|
||||
return Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -117,7 +124,7 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
|
||||
).pipe(Effect.withSpan("InstanceStore.load"))
|
||||
}
|
||||
|
||||
const reload = (input: LoadInput): Effect.Effect<InstanceContext> => {
|
||||
const reload = <R>(input: LoadInput<R>): Effect.Effect<InstanceContext, never, R> => {
|
||||
const directory = AppFileSystem.resolve(input.directory)
|
||||
return Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -171,7 +178,7 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
|
||||
return yield* cachedDisposeAll
|
||||
})
|
||||
|
||||
const provide = <A, E, R>(input: LoadInput, effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
|
||||
const provide = <A, E, R, R2>(input: LoadInput<R2>, effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R | R2> =>
|
||||
load(input).pipe(Effect.flatMap((ctx) => effect.pipe(Effect.provideService(InstanceRef, ctx))))
|
||||
|
||||
yield* Effect.addFinalizer(() => disposeAll().pipe(Effect.ignore))
|
||||
@@ -188,4 +195,13 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Project.defaultLayer))
|
||||
|
||||
export const runtime = makeRuntime(Service, defaultLayer)
|
||||
|
||||
// Promise-returning helpers for callers without an Effect runtime in scope.
|
||||
// They route through `runtime` (not a yielded Service from a fresh runtime)
|
||||
// so they share the cache that `Instance.provide` populates.
|
||||
export const disposeInstance = (ctx: InstanceContext) => runtime.runPromise((store) => store.dispose(ctx))
|
||||
export const disposeAllInstances = () => runtime.runPromise((store) => store.disposeAll())
|
||||
export const reloadInstance = (input: LoadInput) => runtime.runPromise((store) => store.reload(input))
|
||||
|
||||
export * as InstanceStore from "./instance-store"
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { Effect } from "effect"
|
||||
import { context, type InstanceContext } from "./instance-context"
|
||||
import { InstanceStore } from "./instance-store"
|
||||
|
||||
export type { InstanceContext } from "./instance-context"
|
||||
export type { LoadInput } from "./instance-store"
|
||||
|
||||
export const Instance = {
|
||||
async provide<R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }): Promise<R> {
|
||||
const ctx = await InstanceStore.runtime.runPromise((store) =>
|
||||
store.load({ directory: input.directory, init: input.init }),
|
||||
)
|
||||
return context.provide(ctx, async () => input.fn())
|
||||
},
|
||||
get current() {
|
||||
return context.use()
|
||||
},
|
||||
|
||||
@@ -10,9 +10,6 @@ import { BusEvent } from "@/bus/bus-event"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { which } from "../util/which"
|
||||
import { ProjectID } from "./schema"
|
||||
import { Bus } from "@/bus"
|
||||
import { Command } from "@/command"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { Effect, Layer, Path, Scope, Context, Stream, Types, Schema } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { NodePath } from "@effect/platform-node"
|
||||
@@ -111,12 +108,6 @@ export type UpdatePayload = Types.DeepMutable<Schema.Schema.Type<typeof UpdatePa
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Interface {
|
||||
/**
|
||||
* Per-instance setup. Subscribes to the `/init` slash command for the
|
||||
* current instance and stamps the project's initialized timestamp when it
|
||||
* fires. Subscription lifetime is tied to the per-instance state scope.
|
||||
*/
|
||||
readonly init: () => Effect.Effect<void>
|
||||
readonly fromDirectory: (directory: string) => Effect.Effect<{ project: Info; sandbox: string }>
|
||||
readonly discover: (input: Info) => Effect.Effect<void>
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
@@ -136,14 +127,13 @@ type GitResult = { code: number; text: string; stderr: string }
|
||||
export const layer: Layer.Layer<
|
||||
Service,
|
||||
never,
|
||||
AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner | Bus.Service
|
||||
AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner
|
||||
> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const pathSvc = yield* Path.Path
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
const bus = yield* Bus.Service
|
||||
|
||||
const git = Effect.fnUntraced(
|
||||
function* (args: string[], opts?: { cwd?: string }) {
|
||||
@@ -427,21 +417,6 @@ export const layer: Layer.Layer<
|
||||
)
|
||||
})
|
||||
|
||||
const initState = yield* InstanceState.make(
|
||||
Effect.fn("Project.initState")(function* (ctx) {
|
||||
yield* bus.subscribe(Command.Event.Executed).pipe(
|
||||
Stream.runForEach((payload) =>
|
||||
payload.properties.name === Command.Default.INIT ? setInitialized(ctx.project.id) : Effect.void,
|
||||
),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const init = Effect.fn("Project.init")(function* () {
|
||||
yield* InstanceState.get(initState)
|
||||
})
|
||||
|
||||
const sandboxes = Effect.fn("Project.sandboxes")(function* (id: ProjectID) {
|
||||
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
|
||||
if (!row) return []
|
||||
@@ -491,7 +466,6 @@ export const layer: Layer.Layer<
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
init,
|
||||
fromDirectory,
|
||||
discover,
|
||||
list,
|
||||
@@ -507,7 +481,6 @@ export const layer: Layer.Layer<
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Bus.defaultLayer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(NodePath.layer),
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Effect, Layer, Context, Schema, Stream, Scope } from "effect"
|
||||
import { formatPatch, structuredPatch } from "diff"
|
||||
import path from "path"
|
||||
import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
import { Git } from "@/git"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
@@ -10,11 +12,20 @@ import { zod } from "@/util/effect-zod"
|
||||
import { NonNegativeInt, withStatics } from "@/util/schema"
|
||||
|
||||
const log = Log.create({ service: "vcs" })
|
||||
const PATCH_CONTEXT_LINES = 2_147_483_647
|
||||
const MAX_PATCH_BYTES = 10_000_000
|
||||
const MAX_TOTAL_PATCH_BYTES = 10_000_000
|
||||
|
||||
const emptyPatch = (file: string) => formatPatch(structuredPatch(file, file, "", "", "", "", { context: 0 }))
|
||||
const count = (text: string) => {
|
||||
if (!text) return 0
|
||||
if (!text.endsWith("\n")) return text.split("\n").length
|
||||
return text.slice(0, -1).split("\n").length
|
||||
}
|
||||
|
||||
const work = Effect.fnUntraced(function* (fs: AppFileSystem.Interface, cwd: string, file: string) {
|
||||
const full = path.join(cwd, file)
|
||||
if (!(yield* fs.exists(full).pipe(Effect.orDie))) return ""
|
||||
const buf = yield* fs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array())))
|
||||
if (Buffer.from(buf).includes(0)) return ""
|
||||
return Buffer.from(buf).toString("utf8")
|
||||
})
|
||||
|
||||
const nums = (list: Git.Stat[]) =>
|
||||
new Map(list.map((item) => [item.file, { additions: item.additions, deletions: item.deletions }] as const))
|
||||
@@ -27,168 +38,59 @@ const merge = (...lists: Git.Item[][]) => {
|
||||
return [...out.values()]
|
||||
}
|
||||
|
||||
const emptyBatch = () => ({ patches: new Map<string, string>(), capped: false })
|
||||
|
||||
const parseQuotedPath = (value: string) => {
|
||||
let out = ""
|
||||
for (let idx = 1; idx < value.length; idx++) {
|
||||
const char = value[idx]
|
||||
if (char === '"') return { value: out, end: idx + 1 }
|
||||
if (char !== "\\") {
|
||||
out += char
|
||||
continue
|
||||
}
|
||||
|
||||
const next = value[++idx]
|
||||
if (next === "t") out += "\t"
|
||||
else if (next === "n") out += "\n"
|
||||
else if (next === "r") out += "\r"
|
||||
else if (next === '"' || next === "\\") out += next
|
||||
else out += next ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
const parsePathToken = (value: string) => {
|
||||
if (!value.startsWith('"')) return value.split("\t")[0]
|
||||
return parseQuotedPath(value)?.value ?? value
|
||||
}
|
||||
|
||||
const fileFromDiffPath = (value: string | undefined) => {
|
||||
if (!value || value === "/dev/null") return
|
||||
const file = parsePathToken(value)
|
||||
if (file.startsWith("a/") || file.startsWith("b/")) return file.slice(2)
|
||||
return file
|
||||
}
|
||||
|
||||
const fileFromGitHeader = (header: string) => {
|
||||
if (header.startsWith('"')) {
|
||||
const first = parseQuotedPath(header)
|
||||
const second = first ? header.slice(first.end).trimStart() : undefined
|
||||
if (!second) return
|
||||
if (!second.startsWith('"')) return fileFromDiffPath(second)
|
||||
return fileFromDiffPath(parseQuotedPath(second)?.value)
|
||||
}
|
||||
|
||||
const separator = header.indexOf(" b/")
|
||||
if (separator === -1) return
|
||||
return fileFromDiffPath(header.slice(separator + 1))
|
||||
}
|
||||
|
||||
const fileFromPatchChunk = (chunk: string) => {
|
||||
const next = /^\+\+\+ (.+)$/m.exec(chunk)?.[1]
|
||||
const before = /^--- (.+)$/m.exec(chunk)?.[1]
|
||||
const file = fileFromDiffPath(next) ?? fileFromDiffPath(before)
|
||||
if (file) return file
|
||||
|
||||
const header = /^diff --git (.+)$/m.exec(chunk)?.[1]
|
||||
return fileFromGitHeader(header ?? "")
|
||||
}
|
||||
|
||||
const splitGitPatch = (patch: Git.Patch) => {
|
||||
const starts = [...patch.text.matchAll(/^diff --git /gm)].map((match) => match.index)
|
||||
const chunks = starts.map((start, index) => patch.text.slice(start, starts[index + 1] ?? patch.text.length))
|
||||
if (!patch.truncated) return chunks
|
||||
return chunks.slice(0, -1)
|
||||
}
|
||||
|
||||
const batchPatches = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, ref: string, list: Git.Item[]) {
|
||||
if (list.length === 0) return { patches: new Map<string, string>(), capped: false }
|
||||
|
||||
const result = yield* git.patchAll(cwd, ref, {
|
||||
context: PATCH_CONTEXT_LINES,
|
||||
maxOutputBytes: MAX_TOTAL_PATCH_BYTES,
|
||||
})
|
||||
if (result.truncated) log.warn("batched patch exceeded byte limit", { max: MAX_TOTAL_PATCH_BYTES })
|
||||
|
||||
return {
|
||||
patches: splitGitPatch(result).reduce((acc, patch, index) => {
|
||||
const file = fileFromPatchChunk(patch) ?? list[index]?.file
|
||||
if (!file) return acc
|
||||
acc.set(file, (acc.get(file) ?? "") + patch)
|
||||
return acc
|
||||
}, new Map<string, string>()),
|
||||
capped: result.truncated,
|
||||
}
|
||||
})
|
||||
|
||||
const nativePatch = Effect.fnUntraced(function* (
|
||||
git: Git.Interface,
|
||||
cwd: string,
|
||||
ref: string | undefined,
|
||||
item: Git.Item,
|
||||
) {
|
||||
const result =
|
||||
item.code === "??" || !ref
|
||||
? yield* git.patchUntracked(cwd, item.file, { context: PATCH_CONTEXT_LINES, maxOutputBytes: MAX_PATCH_BYTES })
|
||||
: yield* git.patch(cwd, ref, item.file, { context: PATCH_CONTEXT_LINES, maxOutputBytes: MAX_PATCH_BYTES })
|
||||
if (!result.truncated && result.text) return result.text
|
||||
|
||||
if (result.truncated) log.warn("patch exceeded byte limit", { file: item.file, max: MAX_PATCH_BYTES })
|
||||
return emptyPatch(item.file)
|
||||
})
|
||||
|
||||
const totalPatch = (file: string, patch: string, total: number) => {
|
||||
if (total + Buffer.byteLength(patch) <= MAX_TOTAL_PATCH_BYTES) return { patch, capped: false }
|
||||
log.warn("total patch budget exceeded", { file, max: MAX_TOTAL_PATCH_BYTES })
|
||||
return { patch: emptyPatch(file), capped: true }
|
||||
}
|
||||
|
||||
const patchForItem = Effect.fnUntraced(function* (
|
||||
git: Git.Interface,
|
||||
cwd: string,
|
||||
ref: string | undefined,
|
||||
item: Git.Item,
|
||||
batch: { patches: Map<string, string>; capped: boolean },
|
||||
capped: boolean,
|
||||
) {
|
||||
if (capped) return emptyPatch(item.file)
|
||||
|
||||
const batched = batch.patches.get(item.file)
|
||||
if (batched !== undefined) return batched
|
||||
if (item.code !== "??" && batch.capped) return emptyPatch(item.file)
|
||||
return yield* nativePatch(git, cwd, ref, item)
|
||||
})
|
||||
|
||||
const files = Effect.fnUntraced(function* (
|
||||
fs: AppFileSystem.Interface,
|
||||
git: Git.Interface,
|
||||
cwd: string,
|
||||
ref: string | undefined,
|
||||
list: Git.Item[],
|
||||
map: Map<string, { additions: number; deletions: number }>,
|
||||
batch: { patches: Map<string, string>; capped: boolean },
|
||||
) {
|
||||
const next: FileDiff[] = []
|
||||
let total = 0
|
||||
let capped = false
|
||||
|
||||
for (const item of list.toSorted((a, b) => a.file.localeCompare(b.file))) {
|
||||
const stat = map.get(item.file) ?? (item.status === "added" ? yield* git.statUntracked(cwd, item.file) : undefined)
|
||||
const patch = yield* patchForItem(git, cwd, ref, item, batch, capped)
|
||||
const result: { patch: string; capped: boolean } = capped
|
||||
? { patch, capped: true }
|
||||
: totalPatch(item.file, patch, total)
|
||||
capped = capped || result.capped
|
||||
if (!capped) {
|
||||
total += Buffer.byteLength(result.patch)
|
||||
capped = total >= MAX_TOTAL_PATCH_BYTES
|
||||
}
|
||||
next.push({
|
||||
file: item.file,
|
||||
patch: result.patch,
|
||||
additions: stat?.additions ?? 0,
|
||||
deletions: stat?.deletions ?? 0,
|
||||
status: item.status,
|
||||
})
|
||||
}
|
||||
|
||||
return next
|
||||
const base = ref ? yield* git.prefix(cwd) : ""
|
||||
const patch = (file: string, before: string, after: string) =>
|
||||
formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER }))
|
||||
const next = yield* Effect.forEach(
|
||||
list,
|
||||
(item) =>
|
||||
Effect.gen(function* () {
|
||||
const before = item.status === "added" || !ref ? "" : yield* git.show(cwd, ref, item.file, base)
|
||||
const after = item.status === "deleted" ? "" : yield* work(fs, cwd, item.file)
|
||||
const stat = map.get(item.file)
|
||||
return {
|
||||
file: item.file,
|
||||
patch: patch(item.file, before, after),
|
||||
additions: stat?.additions ?? (item.status === "added" ? count(after) : 0),
|
||||
deletions: stat?.deletions ?? (item.status === "deleted" ? count(before) : 0),
|
||||
status: item.status,
|
||||
} satisfies FileDiff
|
||||
}),
|
||||
{ concurrency: 8 },
|
||||
)
|
||||
return next.toSorted((a, b) => a.file.localeCompare(b.file))
|
||||
})
|
||||
|
||||
const diffAgainstRef = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, ref: string) {
|
||||
const track = Effect.fnUntraced(function* (
|
||||
fs: AppFileSystem.Interface,
|
||||
git: Git.Interface,
|
||||
cwd: string,
|
||||
ref: string | undefined,
|
||||
) {
|
||||
if (!ref) return yield* files(fs, git, cwd, ref, yield* git.status(cwd), new Map())
|
||||
const [list, stats] = yield* Effect.all([git.status(cwd), git.stats(cwd, ref)], { concurrency: 2 })
|
||||
return yield* files(fs, git, cwd, ref, list, nums(stats))
|
||||
})
|
||||
|
||||
const compare = Effect.fnUntraced(function* (
|
||||
fs: AppFileSystem.Interface,
|
||||
git: Git.Interface,
|
||||
cwd: string,
|
||||
ref: string,
|
||||
) {
|
||||
const [list, stats, extra] = yield* Effect.all([git.diff(cwd, ref), git.stats(cwd, ref), git.status(cwd)], {
|
||||
concurrency: 3,
|
||||
})
|
||||
return yield* files(
|
||||
fs,
|
||||
git,
|
||||
cwd,
|
||||
ref,
|
||||
@@ -197,15 +99,9 @@ const diffAgainstRef = Effect.fnUntraced(function* (git: Git.Interface, cwd: str
|
||||
extra.filter((item) => item.code === "??"),
|
||||
),
|
||||
nums(stats),
|
||||
yield* batchPatches(git, cwd, ref, list),
|
||||
)
|
||||
})
|
||||
|
||||
const track = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, ref: string | undefined) {
|
||||
if (!ref) return yield* files(git, cwd, ref, yield* git.status(cwd), new Map(), emptyBatch())
|
||||
return yield* diffAgainstRef(git, cwd, ref)
|
||||
})
|
||||
|
||||
export const Mode = Schema.Literals(["git", "branch"]).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type Mode = Schema.Schema.Type<typeof Mode>
|
||||
|
||||
@@ -251,9 +147,10 @@ interface State {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Vcs") {}
|
||||
|
||||
export const layer: Layer.Layer<Service, never, Git.Service | Bus.Service> = Layer.effect(
|
||||
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Git.Service | Bus.Service> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const git = yield* Git.Service
|
||||
const bus = yield* Bus.Service
|
||||
const scope = yield* Scope.Scope
|
||||
@@ -307,19 +204,23 @@ export const layer: Layer.Layer<Service, never, Git.Service | Bus.Service> = Lay
|
||||
const ctx = yield* InstanceState.context
|
||||
if (ctx.project.vcs !== "git") return []
|
||||
if (mode === "git") {
|
||||
return yield* track(git, ctx.directory, (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined)
|
||||
return yield* track(fs, git, ctx.directory, (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined)
|
||||
}
|
||||
|
||||
if (!value.root) return []
|
||||
if (value.current && value.current === value.root.name) return []
|
||||
const ref = yield* git.mergeBase(ctx.directory, value.root.ref)
|
||||
if (!ref) return []
|
||||
return yield* diffAgainstRef(git, ctx.directory, ref)
|
||||
return yield* compare(fs, git, ctx.directory, ref)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Git.defaultLayer), Layer.provide(Bus.layer))
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Bus.layer),
|
||||
)
|
||||
|
||||
export * as Vcs from "./vcs"
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { context } from "./instance-context"
|
||||
import { InstanceStore } from "./instance-store"
|
||||
|
||||
export async function provide<R>(input: { directory: string; fn: () => R }): Promise<R> {
|
||||
const ctx = await AppRuntime.runPromise(
|
||||
InstanceStore.Service.use((store) => store.load({ directory: input.directory })),
|
||||
)
|
||||
return context.provide(ctx, () => input.fn())
|
||||
}
|
||||
|
||||
export * as WithInstance from "./with-instance"
|
||||
@@ -1,48 +0,0 @@
|
||||
export * as ServerAuth from "./auth"
|
||||
|
||||
import { ConfigService } from "@/effect/config-service"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Config as EffectConfig, Context, Option, Redacted } from "effect"
|
||||
|
||||
export type Credentials = {
|
||||
password?: string
|
||||
username?: string
|
||||
}
|
||||
|
||||
export type DecodedCredentials = {
|
||||
readonly username: string
|
||||
readonly password: Redacted.Redacted
|
||||
}
|
||||
|
||||
export class Config extends ConfigService.Service<Config>()("@opencode/ServerAuthConfig", {
|
||||
password: EffectConfig.string("OPENCODE_SERVER_PASSWORD").pipe(EffectConfig.option),
|
||||
username: EffectConfig.string("OPENCODE_SERVER_USERNAME").pipe(EffectConfig.withDefault("opencode")),
|
||||
}) {}
|
||||
|
||||
export type Info = Context.Service.Shape<typeof Config>
|
||||
|
||||
export function required(config: Info) {
|
||||
return Option.isSome(config.password) && config.password.value !== ""
|
||||
}
|
||||
|
||||
export function authorized(credentials: DecodedCredentials, config: Info) {
|
||||
return (
|
||||
Option.isSome(config.password) &&
|
||||
credentials.username === config.username &&
|
||||
Redacted.value(credentials.password) === config.password.value
|
||||
)
|
||||
}
|
||||
|
||||
export function header(credentials?: Credentials) {
|
||||
const password = credentials?.password ?? Flag.OPENCODE_SERVER_PASSWORD
|
||||
if (!password) return undefined
|
||||
|
||||
const username = credentials?.username ?? Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
|
||||
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
|
||||
}
|
||||
|
||||
export function headers(credentials?: Credentials) {
|
||||
const authorization = header(credentials)
|
||||
if (!authorization) return undefined
|
||||
return { Authorization: authorization }
|
||||
}
|
||||
@@ -1,8 +1,78 @@
|
||||
import type { MiddlewareHandler } from "hono"
|
||||
import { Database } from "@/storage/db"
|
||||
import { inArray } from "drizzle-orm"
|
||||
import { EventSequenceTable } from "@/sync/event.sql"
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
import type { WorkspaceID } from "@/control-plane/schema"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { HEADER, diff, load } from "./shared/fence"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Effect } from "effect"
|
||||
|
||||
const log = Log.create({ service: "fence-middleware" })
|
||||
const HEADER = "x-opencode-sync"
|
||||
type State = Record<string, number>
|
||||
const log = Log.create({ service: "fence" })
|
||||
|
||||
export function load(ids?: string[]) {
|
||||
const rows = Database.use((db) => {
|
||||
if (!ids?.length) {
|
||||
return db.select().from(EventSequenceTable).all()
|
||||
}
|
||||
|
||||
return db.select().from(EventSequenceTable).where(inArray(EventSequenceTable.aggregate_id, ids)).all()
|
||||
})
|
||||
|
||||
return Object.fromEntries(rows.map((row) => [row.aggregate_id, row.seq])) as State
|
||||
}
|
||||
|
||||
export function diff(prev: State, next: State) {
|
||||
const ids = new Set([...Object.keys(prev), ...Object.keys(next)])
|
||||
return Object.fromEntries(
|
||||
[...ids]
|
||||
.map((id) => [id, next[id] ?? -1] as const)
|
||||
.filter(([id, seq]) => {
|
||||
return (prev[id] ?? -1) !== seq
|
||||
}),
|
||||
) as State
|
||||
}
|
||||
|
||||
export function parse(headers: Headers) {
|
||||
const raw = headers.get(HEADER)
|
||||
if (!raw) return
|
||||
|
||||
let data
|
||||
|
||||
try {
|
||||
data = JSON.parse(raw)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
if (!data || typeof data !== "object") return
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(data).filter(([id, seq]) => {
|
||||
return typeof id === "string" && Number.isInteger(seq)
|
||||
}),
|
||||
) as State
|
||||
}
|
||||
|
||||
export function waitEffect(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) {
|
||||
return Effect.gen(function* () {
|
||||
log.info("waiting for state", {
|
||||
workspaceID,
|
||||
state,
|
||||
})
|
||||
yield* Workspace.Service.use((workspace) => workspace.waitForSync(workspaceID, state, signal))
|
||||
log.info("state fully synced", {
|
||||
workspaceID,
|
||||
state,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function wait(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) {
|
||||
await AppRuntime.runPromise(waitEffect(workspaceID, state, signal))
|
||||
}
|
||||
|
||||
export const FenceMiddleware: MiddlewareHandler = async (c, next) => {
|
||||
if (c.req.method === "GET" || c.req.method === "HEAD" || c.req.method === "OPTIONS") return next()
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Effect } from "effect"
|
||||
import { Event } from "./event"
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
export const emitGlobalDisposed = Effect.sync(() =>
|
||||
GlobalBus.emit("event", {
|
||||
directory: "global",
|
||||
payload: {
|
||||
type: Event.Disposed.type,
|
||||
properties: {},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
export const disposeAllInstancesAndEmitGlobalDisposed = Effect.fn("Server.disposeAllInstancesAndEmitGlobalDisposed")(
|
||||
function* (options?: { swallowErrors?: boolean }) {
|
||||
const store = yield* InstanceStore.Service
|
||||
yield* Effect.gen(function* () {
|
||||
yield* options?.swallowErrors
|
||||
? store.disposeAll().pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.sync(() => {
|
||||
log.warn("global disposal failed", { cause })
|
||||
}),
|
||||
),
|
||||
)
|
||||
: store.disposeAll()
|
||||
yield* emitGlobalDisposed
|
||||
}).pipe(Effect.uninterruptible)
|
||||
},
|
||||
)
|
||||
|
||||
export * as GlobalLifecycle from "./global-lifecycle"
|
||||
@@ -1,244 +0,0 @@
|
||||
// TODO: Node adapter forthcoming — same pattern but using `node:http` + `ws` library,
|
||||
// and `node:http`'s `upgrade` event.
|
||||
//
|
||||
// This module is a Bun-only proof-of-concept for a native `Bun.serve` listener that
|
||||
// drives the experimental HttpApi handler directly (no Hono in the middle) and handles
|
||||
// WebSocket upgrades inline based on path-matching. It exists to validate the pattern
|
||||
// before deleting the Hono backend; `Server.listen()` is intentionally NOT wired to it.
|
||||
|
||||
import type { ServerWebSocket } from "bun"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { WithInstance } from "@/project/with-instance"
|
||||
import { Pty } from "@/pty"
|
||||
import { handlePtyInput } from "@/pty/input"
|
||||
import { PtyID } from "@/pty/schema"
|
||||
import { PtyPaths } from "@/server/routes/instance/httpapi/groups/pty"
|
||||
import { ExperimentalHttpApiServer } from "@/server/routes/instance/httpapi/server"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import type { CorsOptions } from "./cors"
|
||||
|
||||
const log = Log.create({ service: "httpapi-listener" })
|
||||
const decodePtyID = Schema.decodeUnknownSync(PtyID)
|
||||
|
||||
export type Listener = {
|
||||
hostname: string
|
||||
port: number
|
||||
url: URL
|
||||
stop: (close?: boolean) => Promise<void>
|
||||
}
|
||||
|
||||
export type ListenOptions = CorsOptions & {
|
||||
port: number
|
||||
hostname: string
|
||||
}
|
||||
|
||||
type WsKind = { kind: "pty"; ptyID: string; cursor: number | undefined; directory: string }
|
||||
|
||||
type PtyHandler = {
|
||||
onMessage: (message: string | ArrayBuffer) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
type WsState = WsKind & {
|
||||
handler?: PtyHandler
|
||||
pending: Array<string | Uint8Array>
|
||||
ready: boolean
|
||||
closed: boolean
|
||||
}
|
||||
|
||||
// Derive from the OpenAPI path so this stays in sync if the route literal moves.
|
||||
const ptyConnectPattern = new RegExp(`^${PtyPaths.connect.replace(/:[^/]+/g, "([^/]+)")}$`)
|
||||
|
||||
function parseCursor(value: string | null): number | undefined {
|
||||
if (!value) return undefined
|
||||
const parsed = Number(value)
|
||||
if (!Number.isSafeInteger(parsed) || parsed < -1) return undefined
|
||||
return parsed
|
||||
}
|
||||
|
||||
function asAdapter(ws: ServerWebSocket<WsState>) {
|
||||
return {
|
||||
get readyState() {
|
||||
return ws.readyState
|
||||
},
|
||||
send: (data: string | Uint8Array | ArrayBuffer) => {
|
||||
try {
|
||||
if (data instanceof ArrayBuffer) ws.send(new Uint8Array(data))
|
||||
else ws.send(data)
|
||||
} catch {
|
||||
// socket likely already closed; ignore
|
||||
}
|
||||
},
|
||||
close: (code?: number, reason?: string) => {
|
||||
try {
|
||||
ws.close(code, reason)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spin up a native Bun.serve that:
|
||||
* 1. Routes all HTTP traffic through the HttpApi web handler.
|
||||
* 2. Intercepts known WebSocket upgrade paths and handles them inline.
|
||||
*
|
||||
* This bypasses Hono entirely. The Hono code path remains untouched.
|
||||
*/
|
||||
export async function listen(opts: ListenOptions): Promise<Listener> {
|
||||
const built = ExperimentalHttpApiServer.webHandler(opts)
|
||||
const handler = built.handler
|
||||
const context = ExperimentalHttpApiServer.context
|
||||
|
||||
const start = (port: number) => {
|
||||
try {
|
||||
return Bun.serve<WsState>({
|
||||
hostname: opts.hostname,
|
||||
port,
|
||||
idleTimeout: 0,
|
||||
fetch(request, server) {
|
||||
const url = new URL(request.url)
|
||||
const ptyMatch = url.pathname.match(ptyConnectPattern)
|
||||
if (ptyMatch && request.headers.get("upgrade")?.toLowerCase() === "websocket") {
|
||||
const ptyID = ptyMatch[1]!
|
||||
const cursor = parseCursor(url.searchParams.get("cursor"))
|
||||
// Resolve the instance directory the same way the HttpApi
|
||||
// `instance-context` middleware does (search params, then header,
|
||||
// then process.cwd()).
|
||||
const directory =
|
||||
url.searchParams.get("directory") ?? request.headers.get("x-opencode-directory") ?? process.cwd()
|
||||
const upgraded = server.upgrade(request, {
|
||||
data: {
|
||||
kind: "pty",
|
||||
ptyID,
|
||||
cursor,
|
||||
directory,
|
||||
pending: [],
|
||||
ready: false,
|
||||
closed: false,
|
||||
} satisfies WsState,
|
||||
})
|
||||
if (upgraded) return undefined
|
||||
return new Response("upgrade failed", { status: 400 })
|
||||
}
|
||||
|
||||
// TODO: workspace-proxy WS upgrade detection. The Hono path forwards via a
|
||||
// remote `new WebSocket(url, ...)` (see ServerProxy.websocket). To support
|
||||
// that here we'd need to (a) resolve the workspace target the same way
|
||||
// `WorkspaceRouterMiddleware` does today, then (b) `server.upgrade(request,
|
||||
// { data: { kind: "proxy", target, headers, protocols } })` and bridge the
|
||||
// ServerWebSocket to a remote WebSocket inside the `websocket` handlers.
|
||||
// Deferred to a follow-up — the proxy story needs more design (auth header
|
||||
// forwarding, fence sync, reconnection semantics) than fits this PR.
|
||||
|
||||
return handler(request as Request, context as never)
|
||||
},
|
||||
websocket: {
|
||||
open(ws) {
|
||||
const data = ws.data
|
||||
if (data.kind !== "pty") {
|
||||
ws.close(1011, "unknown ws kind")
|
||||
return
|
||||
}
|
||||
const id = (() => {
|
||||
try {
|
||||
return decodePtyID(data.ptyID)
|
||||
} catch {
|
||||
ws.close(1008, "invalid pty id")
|
||||
return undefined
|
||||
}
|
||||
})()
|
||||
if (!id) return
|
||||
;(async () => {
|
||||
const result = await WithInstance.provide({
|
||||
directory: data.directory,
|
||||
fn: () =>
|
||||
AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
return yield* pty.connect(id, asAdapter(ws), data.cursor)
|
||||
}).pipe(Effect.withSpan("HttpApiListener.pty.connect.open")),
|
||||
),
|
||||
})
|
||||
return await result
|
||||
})()
|
||||
.then((handler) => {
|
||||
if (data.closed) {
|
||||
handler?.onClose()
|
||||
return
|
||||
}
|
||||
if (!handler) {
|
||||
ws.close(4404, "session not found")
|
||||
return
|
||||
}
|
||||
data.handler = handler
|
||||
data.ready = true
|
||||
for (const msg of data.pending) {
|
||||
AppRuntime.runPromise(handlePtyInput(handler, msg)).catch(() => undefined)
|
||||
}
|
||||
data.pending.length = 0
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error("pty connect failed", { error: err })
|
||||
ws.close(1011, "pty connect failed")
|
||||
})
|
||||
},
|
||||
message(ws, message) {
|
||||
const data = ws.data
|
||||
if (data.kind !== "pty") return
|
||||
const payload =
|
||||
typeof message === "string"
|
||||
? message
|
||||
: message instanceof Buffer
|
||||
? new Uint8Array(message.buffer, message.byteOffset, message.byteLength)
|
||||
: (message as Uint8Array)
|
||||
if (!data.ready || !data.handler) {
|
||||
data.pending.push(payload)
|
||||
return
|
||||
}
|
||||
AppRuntime.runPromise(handlePtyInput(data.handler, payload)).catch(() => undefined)
|
||||
},
|
||||
close(ws) {
|
||||
const data = ws.data
|
||||
data.closed = true
|
||||
data.handler?.onClose()
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
log.error("Bun.serve failed", { error: err })
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
const server = opts.port === 0 ? (start(4096) ?? start(0)) : start(opts.port)
|
||||
if (!server) throw new Error(`Failed to start server on port ${opts.port}`)
|
||||
const port = server.port
|
||||
if (port === undefined) throw new Error("Bun.serve started without a numeric port")
|
||||
|
||||
const url = new URL("http://localhost")
|
||||
url.hostname = opts.hostname
|
||||
url.port = String(port)
|
||||
|
||||
let closing: Promise<void> | undefined
|
||||
return {
|
||||
hostname: opts.hostname,
|
||||
port,
|
||||
url,
|
||||
stop(close?: boolean) {
|
||||
closing ??= (async () => {
|
||||
await server.stop(close)
|
||||
// NOTE: we deliberately do NOT call `built.dispose()` here. The
|
||||
// underlying `webHandler` is memoized at module level (same as the
|
||||
// Hono path), so disposing it would tear down shared services for
|
||||
// every other consumer in the process. Lifecycle teardown is owned
|
||||
// by the AppRuntime itself.
|
||||
})()
|
||||
return closing
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export * as HttpApiListener from "./httpapi-listener"
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from "hono"
|
||||
import type { UpgradeWebSocket } from "hono/ws"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import * as Fence from "./shared/fence"
|
||||
import * as Fence from "./fence"
|
||||
import type { WorkspaceID } from "@/control-plane/schema"
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user