mirror of
https://github.com/cline/cline.git
synced 2026-09-04 20:02:30 +08:00
Compare commits
88 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b43fb7777c | |||
| 8ea71e3278 | |||
| f4abb7e14b | |||
| 1e08d8f277 | |||
| 8bebcd1f37 | |||
| 14f9def7ed | |||
| 2e6c25785e | |||
| b565e5b9ca | |||
| 0313c34b1a | |||
| d0f49330a3 | |||
| db31380268 | |||
| b123f2eb54 | |||
| 303722a559 | |||
| 5fff9cbb4c | |||
| 70e0a3731d | |||
| d52af3112d | |||
| eccb0db669 | |||
| a9af97a83f | |||
| 53388f5f9c | |||
| 093e9dd3d5 | |||
| d35f4a043b | |||
| bd9ee23f6c | |||
| 10ff456e93 | |||
| cb34ba5cf9 | |||
| 2ed4b811be | |||
| 7bf542ddab | |||
| 1cb61ab925 | |||
| 7bf495878e | |||
| 677a5cd915 | |||
| 4524e884a7 | |||
| 75d1d6654b | |||
| 45027ee4e3 | |||
| 7f8fd0b62d | |||
| 6df53ddf64 | |||
| 243b6fe9d9 | |||
| edd6e2c29b | |||
| 1eb28e553e | |||
| 42225f9219 | |||
| da759853ea | |||
| 6fed0f0d38 | |||
| b456ce73dd | |||
| 2b9d1c3e6a | |||
| 82277128aa | |||
| ee4edfeaf6 | |||
| 17b0ad7a76 | |||
| be8bb302a8 | |||
| f4f573c395 | |||
| e03b4577b4 | |||
| 16d36e53f2 | |||
| 74a1910505 | |||
| f3db7a0a98 | |||
| 2b62f7b447 | |||
| 6b8b5726c3 | |||
| f6d8e54089 | |||
| ef09caa3e6 | |||
| bdb5f30f5e | |||
| 2fa0be8940 | |||
| 2e5a565090 | |||
| eb95908737 | |||
| b091c72e40 | |||
| ebf5b52e9b | |||
| 13d801b578 | |||
| 642c2f643b | |||
| 528c6b8ff2 | |||
| 2f1c94505a | |||
| 5b7005b003 | |||
| e48a7c9c91 | |||
| 71e1c59ab8 | |||
| d7b61de54f | |||
| 7350294f7d | |||
| fe90cbfdf1 | |||
| 44cf50cee7 | |||
| b39c7e0319 | |||
| 378fdf147b | |||
| 9c62406da9 | |||
| a988d6a6ee | |||
| f1ec8c897c | |||
| 0b1f083531 | |||
| d79ad49de3 | |||
| ce78c667cf | |||
| 2140996303 | |||
| 0d1a72f009 | |||
| a6c0cfd5c3 | |||
| 17e8c0cbc9 | |||
| e2b97792b1 | |||
| e4a7c19432 | |||
| 589f32d13e | |||
| 879c085fdb |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix auto-approve checkboxes freezing after "New Task": clear the task-scoped settings overlay when the task view is cleared or switched, so stale task settings no longer shadow global settings
|
||||
@@ -7,7 +7,7 @@ description: Use when preparing, tagging, and publishing an apps/cli npm release
|
||||
|
||||
Use this skill when the user asks to release the CLI, publish `cline`, bump the CLI version, draft release notes, create a `cli-vX.Y.Z` tag, or trigger the CLI publish workflow.
|
||||
|
||||
The CLI is npm-only. Do not add alternate distribution channels. Windows binaries are Authenticode-signed automatically by the publish workflow via Azure Trusted Signing (see the `.github/actions/sign-windows-cli` composite action and "Windows code signing" in `apps/cli/DISTRIBUTION.md`); if the signing secrets are not configured the workflow warns and publishes unsigned binaries. Local publishes (`bun release cli`) do not sign — prefer the GitHub Actions publish path for releases users run on Windows.
|
||||
The CLI is npm-only. Do not add alternate distribution or signing steps.
|
||||
|
||||
> Working directory: run every command below from the repository root. Paths and scripts (e.g. `apps/cli/package.json`, `sdk/packages/`, `bun release cli`, `bun run version`) are written relative to the repo root.
|
||||
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
---
|
||||
name: publish-desktop
|
||||
description: Use when preparing, tagging, and publishing a Cline desktop app (apps/examples/desktop-app) release — stable (desktop-vX.Y.Z from main) or beta (desktop-vX.Y.Z-beta.N from desktop-experimental, shipped as the side-by-side "Cline Beta" app). Guides changelog drafting, version bumps in package.json + tauri.conf.json, tagging, and the desktop-publish GitHub workflow that builds, signs, notarizes, and updates the per-channel auto-update feed.
|
||||
description: Use when preparing, tagging, and publishing a Cline Code desktop app (apps/examples/desktop-app) release — stable (desktop-vX.Y.Z from main) or beta (desktop-vX.Y.Z-beta.N from desktop-experimental, shipped as the side-by-side "Cline Code Beta" app). Guides changelog drafting, version bumps in package.json + tauri.conf.json, tagging, and the desktop-publish GitHub workflow that builds, signs, notarizes, and updates the per-channel auto-update feed.
|
||||
---
|
||||
|
||||
# Desktop App Release
|
||||
|
||||
Use this skill when the user asks to release the desktop app, publish the Cline desktop app, cut a desktop beta, bump the desktop version, create a `desktop-vX.Y.Z` (or `desktop-vX.Y.Z-beta.N`) tag, or trigger the desktop publish workflow.
|
||||
Use this skill when the user asks to release the desktop app, publish Cline Code, cut a desktop beta, bump the desktop version, create a `desktop-vX.Y.Z` (or `desktop-vX.Y.Z-beta.N`) tag, or trigger the desktop publish workflow.
|
||||
|
||||
> Working directory: run every command below from the repository root.
|
||||
|
||||
Desktop releases ship two platforms, built entirely in GitHub Actions — there is no local publish path. macOS: a single signed + notarized universal DMG that runs natively on both Apple Silicon and Intel. Windows: an Authenticode-signed NSIS installer (`<Product>_<version>_x64-setup.exe`), signed via Azure Trusted Signing in the `build-windows` job (jsign through Tauri's `signCommand`, see `apps/examples/desktop-app/scripts/tauri-sign-windows.ps1`; requires the repo-level `AZURE_*` secrets including `AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_DESKTOP`, plus a `PublishDesktop`-environment federated credential on the `cline-cli-signing` Entra app). Installed apps discover new releases automatically through the Tauri updater, so publishing a release is what ships the update to every existing user **on that channel**.
|
||||
Desktop releases are macOS-only today (a single signed + notarized universal DMG that runs natively on both Apple Silicon and Intel) and are built entirely in GitHub Actions — there is no local publish path. Installed apps discover new releases automatically through the Tauri updater, so publishing a release is what ships the update to every existing user **on that channel**.
|
||||
|
||||
## Release contract
|
||||
|
||||
- Two channels, one workflow (`channel` input on `desktop-publish.yml`):
|
||||
- **stable** — tag `desktop-vX.Y.Z` (no suffix; the workflow rejects prerelease suffixes on this channel), cut from `main`, feeds the rolling `desktop-latest` release, ships as "Cline".
|
||||
- **beta** — tag `desktop-vX.Y.Z-beta.N`, cut from `desktop-experimental`, feeds the rolling `desktop-beta` release, ships as "Cline Beta" (separate bundle identifier `bot.cline.app.beta`; installs side by side with stable). Built with the extra `src-tauri/tauri.beta.conf.json` overlay. Process background: `apps/examples/desktop-app/EXPERIMENTAL.md`.
|
||||
- **stable** — tag `desktop-vX.Y.Z` (no suffix; the workflow rejects prerelease suffixes on this channel), cut from `main`, feeds the rolling `desktop-latest` release, ships as "Cline Code".
|
||||
- **beta** — tag `desktop-vX.Y.Z-beta.N`, cut from `desktop-experimental`, feeds the rolling `desktop-beta` release, ships as "Cline Code Beta" (separate bundle identifier `bot.cline.app.beta`; installs side by side with stable). Built with the extra `src-tauri/tauri.beta.conf.json` overlay. Process background: `apps/examples/desktop-app/EXPERIMENTAL.md`.
|
||||
- Version sources (must match each other and the tag): `apps/examples/desktop-app/package.json` and `apps/examples/desktop-app/src-tauri/tauri.conf.json`. (`src-tauri/Cargo.toml` has its own version but `tauri.conf.json` overrides it; no need to touch it.)
|
||||
- Beta versions are prereleases of the **next** stable: stable `0.0.13` → betas `0.0.14-beta.1`, `-beta.2`, … Once a stable ≥ the beta base ships, the next beta bumps its base (`0.0.15-beta.1`).
|
||||
- Release prep includes approved release notes, the version bumps, and an `apps/examples/desktop-app/CHANGELOG.md` update — committed on `main` for stable, on `desktop-experimental` for beta.
|
||||
- Publish path: `.github/workflows/desktop-publish.yml` (workflow_dispatch, requires the tag to exist, point at the checked-out commit, and be reachable from the channel's branch — `origin/main` for stable, `origin/desktop-experimental` for beta).
|
||||
- **Both channels dispatch from `main`.** This is a security invariant, not a convenience: the run executes `main`'s workflow copy and only the checkout points at the tag, so the signing-secret gates (the `github.ref == main` check and the PublishDesktop environment's main-only deployment-branch policy) hold for beta too. Never add `desktop-experimental` to the PublishDesktop deployment-branch policy.
|
||||
- The workflow creates the tag's GitHub release (universal DMG + macOS updater artifact + Windows NSIS installer with its updater signature + `latest.json`; marked prerelease for beta) and refreshes the channel's rolling feed release, which is the static auto-update feed every installed app on that channel polls. Never delete the `desktop-latest` or `desktop-beta` release or tag.
|
||||
- The workflow creates the tag's GitHub release (universal DMG + updater artifact + `latest.json`; marked prerelease for beta) and refreshes the channel's rolling feed release, which is the static auto-update feed every installed app on that channel polls. Never delete the `desktop-latest` or `desktop-beta` release or tag.
|
||||
- The changelog's `## <version>` section (exact-match, not "topmost") is extracted verbatim into the GitHub release body, the Slack announcement, and the updater manifest notes.
|
||||
- Always ask before pushing commits or tags.
|
||||
|
||||
@@ -120,7 +120,7 @@ gh api repos/cline/cline/actions/runs/<run-id>/pending_deployments \
|
||||
|
||||
Nothing after `validate` runs — and no signing key is readable — until then.
|
||||
|
||||
The workflow builds one universal macOS bundle (`tauri build --target universal-apple-darwin` lipos the aarch64 + x86_64 Rust binaries; the Bun sidecar is lipo'd by `build-sidecar-bin.ts`; beta adds the `tauri.beta.conf.json` overlay), verifies every Mach-O in the bundle carries both slices and that the compiled binary embeds exactly its own channel's feed URL, signs with the Developer ID certificate, notarizes with the App Store Connect API key, and signs the updater artifact with the Tauri updater key. In parallel, `build-windows` builds the x64 NSIS installer on a Windows runner, Authenticode-signs every binary via Azure Trusted Signing (Tauri `signCommand` -> `scripts/tauri-sign-windows.ps1`), runs the same feed-endpoint and telemetry guardrails, and verifies the shipped installer with `Get-AuthenticodeSignature`. The release job then creates the GitHub release (prerelease for beta), refreshes the channel's feed (`desktop-latest/latest.json` or `desktop-beta/latest.json`), and posts to Slack. Notarization typically adds 2–10 minutes.
|
||||
The workflow builds one universal macOS bundle (`tauri build --target universal-apple-darwin` lipos the aarch64 + x86_64 Rust binaries; the Bun sidecar is lipo'd by `build-sidecar-bin.ts`; beta adds the `tauri.beta.conf.json` overlay), verifies every Mach-O in the bundle carries both slices and that the compiled binary embeds exactly its own channel's feed URL, signs with the Developer ID certificate, notarizes with the App Store Connect API key, signs the updater artifact with the Tauri updater key, creates the GitHub release (prerelease for beta), refreshes the channel's feed (`desktop-latest/latest.json` or `desktop-beta/latest.json`), and posts to Slack. Notarization typically adds 2–10 minutes.
|
||||
|
||||
If the workflow fails on missing credentials, see "Publish secrets (one-time setup)" below.
|
||||
|
||||
@@ -131,7 +131,7 @@ curl -sL https://github.com/cline/cline/releases/download/desktop-latest/latest.
|
||||
curl -sL https://github.com/cline/cline/releases/download/desktop-beta/latest.json | head -30 # beta
|
||||
```
|
||||
|
||||
The `version` field must be the new release; both `darwin-aarch64` and `darwin-x86_64` entries must point at the same new universal `.app.tar.gz` asset under the release tag (each slice of the fat binary requests its own arch key at runtime, so both keys serve the one artifact), and the `windows-x86_64` entry must point at the new `*_x64-setup.exe` asset. Installed apps on that channel — including older per-arch installs — pick the update up on next launch or within 2 hours.
|
||||
The `version` field must be the new release and both `darwin-aarch64` and `darwin-x86_64` entries must point at the same new universal `.app.tar.gz` asset under the release tag (each slice of the fat binary requests its own arch key at runtime, so both keys serve the one artifact). Installed apps on that channel — including older per-arch installs — pick the update up on next launch or within 2 hours.
|
||||
|
||||
After a **beta** publish, also confirm the stable feed was not touched: `desktop-latest/latest.json` must still serve the previous stable version. (The workflow guards this fail-closed, but it is cheap to verify and catastrophic to miss — the updater comparator is a plain semver "newer than", so a beta manifest on `desktop-latest` would auto-update every stable install onto the beta.)
|
||||
|
||||
|
||||
@@ -93,9 +93,7 @@ Release prep on `main` (PR, not direct push):
|
||||
|
||||
```bash
|
||||
gh workflow run ext-vscode-ab-package.yml --ref main \
|
||||
-f version=<VERSION> -f next-ref=main -f publish=true
|
||||
# (the legacy bundle always builds from the protected legacy-extension branch;
|
||||
# it is deliberately not an input)
|
||||
-f version=<VERSION> -f next-ref=main -f legacy-ref=legacy-extension -f publish=true
|
||||
# publish=false builds an installable .vsix artifact without publishing and
|
||||
# needs NO environment approval — the ungated build job uploads the artifact
|
||||
# and the run completes.
|
||||
@@ -158,9 +156,7 @@ For shipping a fix on the `legacy-extension` branch — or as the **structural r
|
||||
# versions, e.g. combined 4.1.0 live -> hotfix is 4.1.1, not 4.0.13),
|
||||
# add the matching `## [x.y.z]` entry to root CHANGELOG.md, push.
|
||||
gh workflow run ext-vscode-publish-legacy.yml --ref main \
|
||||
-f release-type=release
|
||||
# (the branch is hardcoded to legacy-extension in the workflow; it is
|
||||
# deliberately not an input)
|
||||
-f release-type=release -f branch=legacy-extension
|
||||
```
|
||||
|
||||
npm test suite runs ungated; the publish job waits on the `Publish` environment. This workflow derives + pushes the `v<version>` tag itself and creates the GitHub release — no manual tagging. Publishes to Marketplace **and** Open VSX. The branch is the npm codebase: use `npm`, never `bun`, and expect the old monolith layout (`apps/vscode/src/core/...`).
|
||||
|
||||
@@ -15,8 +15,6 @@ body:
|
||||
- VSCode Extension
|
||||
- JetBrains Plugin
|
||||
- CLI
|
||||
- Desktop App
|
||||
- Cloud Platform
|
||||
default: 0
|
||||
validations:
|
||||
required: true
|
||||
@@ -64,15 +62,13 @@ body:
|
||||
- type: textarea
|
||||
id: ide-diagnostics
|
||||
attributes:
|
||||
label: Diagnostics
|
||||
label: IDE / CLI Diagnostics
|
||||
description: |
|
||||
Paste the diagnostics for your Cline surface. This captures the build, runtime, and host details we need.
|
||||
Paste the "About" diagnostics for your Cline surface. This captures the IDE build, runtime, and host details we need.
|
||||
- VSCode Extension: open `Help → About` (Windows/Linux) or `Code → About Visual Studio Code` (macOS), then copy the info.
|
||||
- JetBrains Plugin: open `Help → About` (Windows/Linux) or `<IDE name> → About` (macOS), then click `Copy` to grab build, runtime, OS, memory, and cores.
|
||||
- CLI: there is no About dialog. Run `cline --version` and paste the output.
|
||||
- Desktop App: paste the app version from the Settings view.
|
||||
- Cloud Platform: paste your browser name and version, plus the page URL where the issue occurred.
|
||||
placeholder: Paste the copied About info, `cline --version` output, or browser/app details here.
|
||||
placeholder: Paste the copied About info or `cline --version` output here.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
name: Sign Windows CLI binaries
|
||||
description: >
|
||||
Authenticode-signs the compiled Windows CLI executables with Azure Trusted
|
||||
Signing (via jsign, so it runs on Linux runners) and verifies the resulting
|
||||
signatures. If the Azure Trusted Signing secrets are not configured, the
|
||||
action logs a warning and exits successfully so releases keep working while
|
||||
signing infrastructure is being provisioned.
|
||||
|
||||
inputs:
|
||||
azure-client-id:
|
||||
description: Client ID of the Entra app with the Trusted Signing Certificate Profile Signer role (OIDC federated credential, no client secret).
|
||||
required: false
|
||||
default: ""
|
||||
azure-tenant-id:
|
||||
description: Entra tenant ID.
|
||||
required: false
|
||||
default: ""
|
||||
azure-subscription-id:
|
||||
description: Azure subscription ID containing the Trusted Signing account.
|
||||
required: false
|
||||
default: ""
|
||||
endpoint:
|
||||
description: Trusted Signing account endpoint, for example https://eus.codesigning.azure.net.
|
||||
required: false
|
||||
default: ""
|
||||
account:
|
||||
description: Trusted Signing account name.
|
||||
required: false
|
||||
default: ""
|
||||
certificate-profile:
|
||||
description: Trusted Signing certificate profile name.
|
||||
required: false
|
||||
default: ""
|
||||
files:
|
||||
description: Newline-separated list of PE files to sign.
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Check signing configuration
|
||||
id: check
|
||||
shell: bash
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ inputs.azure-client-id }}
|
||||
AZURE_TENANT_ID: ${{ inputs.azure-tenant-id }}
|
||||
AZURE_SUBSCRIPTION_ID: ${{ inputs.azure-subscription-id }}
|
||||
SIGNING_ENDPOINT: ${{ inputs.endpoint }}
|
||||
SIGNING_ACCOUNT: ${{ inputs.account }}
|
||||
SIGNING_PROFILE: ${{ inputs.certificate-profile }}
|
||||
run: |
|
||||
missing=()
|
||||
set_count=0
|
||||
for var in AZURE_CLIENT_ID AZURE_TENANT_ID AZURE_SUBSCRIPTION_ID SIGNING_ENDPOINT SIGNING_ACCOUNT SIGNING_PROFILE; do
|
||||
if [ -z "${!var}" ]; then
|
||||
missing+=("$var")
|
||||
else
|
||||
set_count=$((set_count + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "${#missing[@]}" -eq 0 ]; then
|
||||
echo "Azure Trusted Signing is configured; Windows binaries will be signed."
|
||||
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
||||
elif [ "$set_count" -eq 0 ]; then
|
||||
echo "::warning::Azure Trusted Signing is not configured; publishing UNSIGNED Windows binaries. Set the AZURE_* and AZURE_TRUSTED_SIGNING_* repository secrets to enable signing."
|
||||
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
# Partial configuration is almost certainly a typo'd or renamed
|
||||
# secret. Fail loudly instead of silently publishing unsigned.
|
||||
echo "::error::Azure Trusted Signing is PARTIALLY configured; refusing to publish. Missing: ${missing[*]}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Azure login (OIDC)
|
||||
if: steps.check.outputs.enabled == 'true'
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
|
||||
with:
|
||||
client-id: ${{ inputs.azure-client-id }}
|
||||
tenant-id: ${{ inputs.azure-tenant-id }}
|
||||
subscription-id: ${{ inputs.azure-subscription-id }}
|
||||
|
||||
- name: Sign Windows binaries
|
||||
if: steps.check.outputs.enabled == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
SIGNING_ENDPOINT: ${{ inputs.endpoint }}
|
||||
SIGNING_ACCOUNT: ${{ inputs.account }}
|
||||
SIGNING_PROFILE: ${{ inputs.certificate-profile }}
|
||||
FILES: ${{ inputs.files }}
|
||||
JSIGN_VERSION: "7.5"
|
||||
JSIGN_SHA256: "602a51c3545a6dc4fb99bd2ea7152b26d1345916d0c93ddfbd5936cb735af91c"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
JSIGN_JAR="${RUNNER_TEMP}/jsign-${JSIGN_VERSION}.jar"
|
||||
curl -fsSL -o "$JSIGN_JAR" "https://github.com/ebourg/jsign/releases/download/${JSIGN_VERSION}/jsign-${JSIGN_VERSION}.jar"
|
||||
echo "${JSIGN_SHA256} ${JSIGN_JAR}" | sha256sum --check --strict
|
||||
|
||||
JSIGN_STOREPASS=$(az account get-access-token --resource https://codesigning.azure.net --query accessToken --output tsv)
|
||||
echo "::add-mask::${JSIGN_STOREPASS}"
|
||||
export JSIGN_STOREPASS
|
||||
|
||||
# jsign expects the endpoint host, not the URL. Tolerate both the
|
||||
# portal's display form (trailing slash) and the bare form.
|
||||
KEYSTORE="${SIGNING_ENDPOINT#https://}"
|
||||
KEYSTORE="${KEYSTORE%/}"
|
||||
|
||||
while IFS= read -r file; do
|
||||
[ -z "$file" ] && continue
|
||||
echo "Signing ${file}"
|
||||
java -jar "$JSIGN_JAR" \
|
||||
--storetype TRUSTEDSIGNING \
|
||||
--keystore "$KEYSTORE" \
|
||||
--storepass env:JSIGN_STOREPASS \
|
||||
--alias "${SIGNING_ACCOUNT}/${SIGNING_PROFILE}" \
|
||||
--alg SHA-256 \
|
||||
--tsaurl http://timestamp.acs.microsoft.com \
|
||||
--tsmode RFC3161 \
|
||||
--replace \
|
||||
"$file"
|
||||
done <<< "$FILES"
|
||||
|
||||
- name: Verify signatures
|
||||
if: steps.check.outputs.enabled == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
FILES: ${{ inputs.files }}
|
||||
# Authenticode chains anchor to the Microsoft Identity Verification
|
||||
# Root CA 2020, which is not in the Mozilla TLS bundle, so fetch it
|
||||
# explicitly (pinned) for osslsigncode chain validation.
|
||||
MS_ROOT_URL: "https://www.microsoft.com/pkiops/certs/Microsoft%20Identity%20Verification%20Root%20Certificate%20Authority%202020.crt"
|
||||
MS_ROOT_SHA256: "5367f20c7ade0e2bca790915056d086b720c33c1fa2a2661acf787e3292e1270"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if ! command -v osslsigncode >/dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq osslsigncode
|
||||
fi
|
||||
|
||||
MS_ROOT_DER="${RUNNER_TEMP}/ms-identity-root-2020.crt"
|
||||
MS_ROOT_PEM="${RUNNER_TEMP}/ms-identity-root-2020.pem"
|
||||
curl -fsSL -o "$MS_ROOT_DER" "$MS_ROOT_URL"
|
||||
echo "${MS_ROOT_SHA256} ${MS_ROOT_DER}" | sha256sum --check --strict
|
||||
openssl x509 -inform DER -in "$MS_ROOT_DER" -out "$MS_ROOT_PEM"
|
||||
|
||||
while IFS= read -r file; do
|
||||
[ -z "$file" ] && continue
|
||||
echo "Verifying signature on ${file}"
|
||||
# Timestamp countersignature chain is checked separately by Windows;
|
||||
# -ignore-timestamp only skips TSA chain validation here, not the
|
||||
# Authenticode chain itself.
|
||||
osslsigncode verify -in "$file" -CAfile "$MS_ROOT_PEM" -ignore-timestamp
|
||||
done <<< "$FILES"
|
||||
@@ -190,19 +190,6 @@ jobs:
|
||||
ls -lh "$dir/bin/"
|
||||
done
|
||||
|
||||
- name: Sign Windows binaries
|
||||
uses: ./.github/actions/sign-windows-cli
|
||||
with:
|
||||
azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
endpoint: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
|
||||
account: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
|
||||
certificate-profile: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_CLI }}
|
||||
files: |
|
||||
apps/cli/dist/cli-windows-x64/bin/cline.exe
|
||||
apps/cli/dist/cli-windows-arm64/bin/cline.exe
|
||||
|
||||
- name: Publish to NPM with latest tag
|
||||
env:
|
||||
NPM_CONFIG_PROVENANCE: "true"
|
||||
@@ -219,8 +206,6 @@ jobs:
|
||||
|
||||
- name: Get Changelog Entry
|
||||
id: changelog
|
||||
env:
|
||||
RELEASE_URL: https://github.com/${{ github.repository }}/releases/tag/${{ steps.version.outputs.tag }}
|
||||
run: |
|
||||
# Grab content between the first "## " header and the next one in apps/cli/CHANGELOG.md
|
||||
CONTENT=$(awk '/^## [0-9]/{if(found) exit; found=1; next} found{print}' apps/cli/CHANGELOG.md)
|
||||
@@ -228,32 +213,6 @@ jobs:
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
# Slack section blocks reject text longer than 3000 characters, and the
|
||||
# Slack action logs that rejection WITHOUT failing the step - so an
|
||||
# over-long changelog silently drops the release announcement while the
|
||||
# run stays green (cline@3.0.50 hit this). Post a trimmed copy to Slack
|
||||
# and link out to the full notes. The GitHub release body stays whole.
|
||||
SLACK_CONTENT=$(CONTENT="$CONTENT" RELEASE_URL="$RELEASE_URL" python3 -c '
|
||||
import os
|
||||
content = os.environ["CONTENT"]
|
||||
more = "\n\n… <%s|Read the full release notes>" % os.environ["RELEASE_URL"]
|
||||
if len(content) <= 3000:
|
||||
print(content, end="")
|
||||
else:
|
||||
budget = 3000 - len(more)
|
||||
kept, used = [], 0
|
||||
for line in content.splitlines(keepends=True):
|
||||
if used + len(line) > budget:
|
||||
break
|
||||
kept.append(line)
|
||||
used += len(line)
|
||||
body = "".join(kept).rstrip() if kept else content[:budget].rstrip()
|
||||
print(body + more, end="")
|
||||
')
|
||||
echo "slack_content<<SLACK_EOF" >> $GITHUB_OUTPUT
|
||||
echo "$SLACK_CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "SLACK_EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
@@ -289,7 +248,7 @@ jobs:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.slack_content) }}
|
||||
text: ${{ toJSON(steps.changelog.outputs.content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
@@ -460,20 +419,6 @@ jobs:
|
||||
ls -lh "$dir/bin/"
|
||||
done
|
||||
|
||||
- name: Sign Windows binaries
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
uses: ./.github/actions/sign-windows-cli
|
||||
with:
|
||||
azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
endpoint: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
|
||||
account: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
|
||||
certificate-profile: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_CLI }}
|
||||
files: |
|
||||
apps/cli/dist/cli-windows-x64/bin/cline.exe
|
||||
apps/cli/dist/cli-windows-arm64/bin/cline.exe
|
||||
|
||||
- name: Publish to NPM with nightly tag
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
env:
|
||||
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
fi
|
||||
ANCESTOR_REF=main
|
||||
FEED=desktop-latest
|
||||
PRODUCT="Cline"
|
||||
PRODUCT="Cline Code"
|
||||
;;
|
||||
beta)
|
||||
if ! printf "%s\n" "$TAG" | grep -Eq '^desktop-v[0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]+$'; then
|
||||
@@ -120,7 +120,7 @@ jobs:
|
||||
fi
|
||||
ANCESTOR_REF=desktop-experimental
|
||||
FEED=desktop-beta
|
||||
PRODUCT="Cline Beta"
|
||||
PRODUCT="Cline Code Beta"
|
||||
;;
|
||||
*)
|
||||
echo "unknown channel: ${CHANNEL}"
|
||||
@@ -435,7 +435,7 @@ jobs:
|
||||
OUT="dist/publish"
|
||||
mkdir -p "$OUT"
|
||||
|
||||
# "Cline" -> Cline, "Cline Beta" -> Cline-Beta
|
||||
# "Cline Code" -> Cline-Code, "Cline Code Beta" -> Cline-Code-Beta
|
||||
PREFIX="${PRODUCT// /-}"
|
||||
|
||||
DMG=$(find "$BUNDLE_DIR/dmg" -name '*.dmg' -print -quit)
|
||||
@@ -462,282 +462,9 @@ jobs:
|
||||
path: apps/examples/desktop-app/dist/publish/*
|
||||
if-no-files-found: error
|
||||
|
||||
build-windows:
|
||||
name: Build Windows (x64)
|
||||
needs: validate
|
||||
# Same gate rationale as the macOS build job above. This job additionally
|
||||
# needs id-token: write for Azure OIDC: Windows binaries are
|
||||
# Authenticode-signed with Azure Trusted Signing, authenticated through the
|
||||
# PublishDesktop-environment federated credential on the cline-cli-signing
|
||||
# Entra app (subject repo:cline/cline:environment:PublishDesktop).
|
||||
if: github.ref == 'refs/heads/main'
|
||||
environment: PublishDesktop
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 90
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
# All-or-nothing: an unsigned Windows desktop build is never acceptable
|
||||
# (Smart App Control / WDAC block unsigned exes and SmartScreen flags
|
||||
# unsigned installers), and Tauri would skip updater-artifact signing
|
||||
# silently if the updater key were missing. Unlike the CLI pipeline
|
||||
# there is no unsigned fallback here.
|
||||
- name: Verify signing secrets are present
|
||||
shell: bash
|
||||
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_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
|
||||
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
|
||||
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_DESKTOP: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_DESKTOP }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: |
|
||||
missing=()
|
||||
for name in AZURE_CLIENT_ID AZURE_TENANT_ID AZURE_SUBSCRIPTION_ID \
|
||||
AZURE_TRUSTED_SIGNING_ENDPOINT AZURE_TRUSTED_SIGNING_ACCOUNT_NAME \
|
||||
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_DESKTOP \
|
||||
TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD; do
|
||||
[ -n "${!name}" ] || missing+=("$name")
|
||||
done
|
||||
|
||||
if [ ${#missing[@]} -gt 0 ]; then
|
||||
echo "Missing signing secrets for the Windows desktop build:"
|
||||
printf ' - %s\n' "${missing[@]}"
|
||||
echo
|
||||
echo "The AZURE_* names are repository secrets; the TAURI_* names"
|
||||
echo "live in the PublishDesktop environment. Refusing to build an"
|
||||
echo "unsigned Windows desktop release."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "All Windows signing secrets are present."
|
||||
|
||||
# Every action in this job is SHA-pinned (unlike elsewhere in this
|
||||
# file): they run with id-token: write and the updater signing key in
|
||||
# scope, so a hijacked upstream tag must not be able to reach the
|
||||
# signing identity or tamper with what gets signed and uploaded.
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
ref: ${{ needs.validate.outputs.tag }}
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
with:
|
||||
bun-version: "1.3.13"
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable branch
|
||||
with:
|
||||
# With a SHA-pinned action the toolchain no longer comes from the
|
||||
# ref name, so it must be set explicitly.
|
||||
toolchain: stable
|
||||
|
||||
# No Rust build cache, mirroring the macOS job: this job holds the
|
||||
# updater signing key and an Azure signing session, and a restored cache
|
||||
# archive is attacker-controlled if the Actions cache is poisoned.
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build SDK packages
|
||||
run: bun run build:sdk
|
||||
env:
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
|
||||
- name: Azure login (OIDC)
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
# Tauri invokes signCommand once per staged binary (main exe, sidecar,
|
||||
# NSIS uninstaller, and the installer itself). The overlay is generated
|
||||
# here rather than committed because signCommand needs an absolute path
|
||||
# to the signing script on this runner.
|
||||
- name: Write signing config overlay
|
||||
shell: bash
|
||||
run: |
|
||||
SCRIPT_PATH="${GITHUB_WORKSPACE//\\//}/apps/examples/desktop-app/scripts/tauri-sign-windows.ps1"
|
||||
SIGN_CONF="${RUNNER_TEMP//\\//}/tauri-windows-sign.conf.json"
|
||||
cat > "$SIGN_CONF" <<EOF
|
||||
{
|
||||
"\$schema": "https://schema.tauri.app/config/2",
|
||||
"bundle": {
|
||||
"windows": {
|
||||
"signCommand": "pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File ${SCRIPT_PATH} %1"
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
cat "$SIGN_CONF"
|
||||
echo "SIGN_CONF=${SIGN_CONF}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build and sign desktop bundle
|
||||
shell: bash
|
||||
working-directory: apps/examples/desktop-app
|
||||
# NSIS only: the MSI (WiX) target adds nothing for direct-download
|
||||
# distribution and the updater uses the NSIS artifact. $CONFIG_ARGS is
|
||||
# deliberately unquoted: it must word-split into separate flags.
|
||||
run: bunx tauri build --bundles nsis $CONFIG_ARGS --config "$SIGN_CONF"
|
||||
env:
|
||||
CONFIG_ARGS: ${{ needs.validate.outputs.channel == 'beta' && '--config src-tauri/tauri.release.conf.json --config src-tauri/tauri.beta.conf.json' || '--config src-tauri/tauri.release.conf.json' }}
|
||||
# Telemetry inlined into the sidecar at compile time, same as macOS.
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
# Authenticode signing via scripts/tauri-sign-windows.ps1 (jsign +
|
||||
# Azure Trusted Signing; the token comes from the azure/login session)
|
||||
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
|
||||
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
|
||||
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_DESKTOP }}
|
||||
# Updater artifact signing (minisign keypair, same key as macOS)
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
|
||||
# Same guardrail as the macOS job: assert the compiled binary embeds
|
||||
# this channel's updater feed URL and not the other channel's. Checked
|
||||
# on the unbundled main exe because NSIS compresses the installer
|
||||
# contents, which defeats a string search on the installer itself.
|
||||
- name: Verify updater feed endpoint
|
||||
shell: bash
|
||||
working-directory: apps/examples/desktop-app
|
||||
env:
|
||||
CHANNEL: ${{ needs.validate.outputs.channel }}
|
||||
run: |
|
||||
case "$CHANNEL" in
|
||||
stable)
|
||||
WANT="releases/download/desktop-latest/latest.json"
|
||||
FORBID="releases/download/desktop-beta/latest.json"
|
||||
;;
|
||||
beta)
|
||||
WANT="releases/download/desktop-beta/latest.json"
|
||||
FORBID="releases/download/desktop-latest/latest.json"
|
||||
;;
|
||||
*)
|
||||
echo "unknown channel: ${CHANNEL}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
found=0
|
||||
for bin in src-tauri/target/release/*.exe; do
|
||||
if grep -a "$FORBID" "$bin" >/dev/null; then
|
||||
echo "$bin embeds the other channel's feed URL (${FORBID})"
|
||||
exit 1
|
||||
fi
|
||||
if grep -a "$WANT" "$bin" >/dev/null; then
|
||||
found=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$found" -ne 1 ]; then
|
||||
echo "No exe in src-tauri/target/release embeds ${WANT}."
|
||||
echo "The updater endpoint overlay did not apply; check the"
|
||||
echo "--config flags on the build step and tauri.beta.conf.json."
|
||||
exit 1
|
||||
fi
|
||||
echo "Updater endpoint verified: ${WANT}"
|
||||
|
||||
# Same guardrail as the macOS job, run natively on the Windows sidecar.
|
||||
- name: Verify sidecar telemetry config was inlined
|
||||
shell: bash
|
||||
working-directory: apps/examples/desktop-app
|
||||
run: |
|
||||
SELFCHECK=$(./src-tauri/bin/code-sidecar-x86_64-pc-windows-msvc.exe --telemetry-selfcheck)
|
||||
echo "$SELFCHECK"
|
||||
if ! printf '%s' "$SELFCHECK" | grep -q '"enabled":true'; then
|
||||
echo "Packaged sidecar reports telemetry disabled."
|
||||
echo "Check the OTEL_* / TELEMETRY_SERVICE_API_KEY env on the"
|
||||
echo "'Build and sign desktop bundle' step and the --define"
|
||||
echo "inlining in scripts/build-sidecar-bin.ts."
|
||||
exit 1
|
||||
fi
|
||||
if printf '%s' "$SELFCHECK" | grep -Eq '"otlp_endpoint_host":"(invalid-endpoint-url)?"'; then
|
||||
echo "Packaged sidecar reports telemetry enabled but its OTLP"
|
||||
echo "endpoint is missing, unparseable, or not an http(s) URL."
|
||||
echo "Check the OTEL_EXPORTER_OTLP_ENDPOINT secret."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Collect artifacts
|
||||
shell: bash
|
||||
working-directory: apps/examples/desktop-app
|
||||
env:
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
PRODUCT: ${{ needs.validate.outputs.product }}
|
||||
run: |
|
||||
BUNDLE_DIR="src-tauri/target/release/bundle"
|
||||
OUT="dist/publish"
|
||||
mkdir -p "$OUT"
|
||||
|
||||
# "Cline" -> Cline, "Cline Beta" -> Cline-Beta
|
||||
PREFIX="${PRODUCT// /-}"
|
||||
|
||||
SETUP=$(find "$BUNDLE_DIR/nsis" -name '*-setup.exe' -print -quit)
|
||||
if [ -z "$SETUP" ]; then
|
||||
echo "no NSIS installer produced under $BUNDLE_DIR/nsis"
|
||||
exit 1
|
||||
fi
|
||||
# The .sig is the updater (minisign) signature; without it the
|
||||
# manifest generator cannot publish a windows-x86_64 entry.
|
||||
if [ ! -f "${SETUP}.sig" ]; then
|
||||
echo "updater signature missing next to $SETUP"
|
||||
exit 1
|
||||
fi
|
||||
cp "$SETUP" "$OUT/${PREFIX}_${VERSION}_x64-setup.exe"
|
||||
cp "${SETUP}.sig" "$OUT/${PREFIX}_${VERSION}_x64-setup.exe.sig"
|
||||
|
||||
ls -lh "$OUT"
|
||||
|
||||
# Independent Authenticode gate on the exact artifact users download.
|
||||
# The signing script already verifies each file it signs, but this step
|
||||
# would still catch an installer that skipped signCommand entirely.
|
||||
- name: Verify Authenticode signatures
|
||||
shell: pwsh
|
||||
working-directory: apps/examples/desktop-app
|
||||
run: |
|
||||
# The Tauri bundler signs the sidecar in place, so check it here too;
|
||||
# a WDAC-locked machine blocks the app at runtime if the sidecar it
|
||||
# spawns is unsigned, even when the installer itself is fine.
|
||||
$files = @(Get-ChildItem dist/publish/*.exe) + @(Get-Item src-tauri/bin/code-sidecar-x86_64-pc-windows-msvc.exe)
|
||||
if ($files.Count -lt 2) { throw "expected at least the installer and the sidecar to verify" }
|
||||
foreach ($file in $files) {
|
||||
$sig = Get-AuthenticodeSignature $file.FullName
|
||||
if ($sig.Status -ne "Valid") {
|
||||
throw "Invalid Authenticode signature for $($file.Name): $($sig.Status) - $($sig.StatusMessage)"
|
||||
}
|
||||
Write-Host "$($file.Name): Valid ($($sig.SignerCertificate.Subject))"
|
||||
}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: desktop-windows-x64
|
||||
path: apps/examples/desktop-app/dist/publish/*
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
name: Create GitHub release
|
||||
needs: [validate, build, build-windows]
|
||||
needs: [validate, build]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -779,33 +506,6 @@ jobs:
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
printf "%s\n" "$CONTENT" > "$RUNNER_TEMP/release-notes.md"
|
||||
|
||||
# Slack section blocks reject text longer than 3000 characters, and the
|
||||
# Slack action logs that rejection WITHOUT failing the step - so an
|
||||
# over-long changelog silently drops the release announcement while the
|
||||
# run stays green. Post a trimmed copy to Slack and link out to the full
|
||||
# notes. The GitHub release body and updater manifest stay whole.
|
||||
RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${{ needs.validate.outputs.tag }}"
|
||||
SLACK_CONTENT=$(CONTENT="$CONTENT" RELEASE_URL="$RELEASE_URL" python3 -c '
|
||||
import os
|
||||
content = os.environ["CONTENT"]
|
||||
more = "\n\n… <%s|Read the full release notes>" % os.environ["RELEASE_URL"]
|
||||
if len(content) <= 3000:
|
||||
print(content, end="")
|
||||
else:
|
||||
budget = 3000 - len(more)
|
||||
kept, used = [], 0
|
||||
for line in content.splitlines(keepends=True):
|
||||
if used + len(line) > budget:
|
||||
break
|
||||
kept.append(line)
|
||||
used += len(line)
|
||||
body = "".join(kept).rstrip() if kept else content[:budget].rstrip()
|
||||
print(body + more, end="")
|
||||
')
|
||||
echo "slack_content<<SLACK_EOF" >> $GITHUB_OUTPUT
|
||||
echo "$SLACK_CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "SLACK_EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Generate updater manifest
|
||||
env:
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
@@ -880,14 +580,14 @@ jobs:
|
||||
if ! gh release view "$FEED" >/dev/null 2>&1; then
|
||||
if [ "$CHANNEL" = "beta" ]; then
|
||||
gh release create "$FEED" \
|
||||
--title "Cline desktop beta (auto-update feed)" \
|
||||
--title "Cline Code desktop beta (auto-update feed)" \
|
||||
--notes "Rolling release backing the beta desktop app auto-updater. The latest.json asset points at the newest desktop-vX.Y.Z-beta.N release. Only beta installs poll this feed; stable installs use desktop-latest. Do not delete." \
|
||||
--latest=false \
|
||||
--prerelease \
|
||||
--target "$(git rev-parse HEAD)"
|
||||
else
|
||||
gh release create "$FEED" \
|
||||
--title "Cline desktop (auto-update feed)" \
|
||||
--title "Cline Code desktop (auto-update feed)" \
|
||||
--notes "Rolling release backing the desktop app auto-updater. The latest.json asset points at the newest desktop-vX.Y.Z release. Do not delete." \
|
||||
--latest=false \
|
||||
--target "$(git rev-parse HEAD)"
|
||||
@@ -901,7 +601,7 @@ jobs:
|
||||
TAG: ${{ needs.validate.outputs.tag }}
|
||||
FEED: ${{ needs.validate.outputs.feed }}
|
||||
run: |
|
||||
echo "Published Cline desktop v${VERSION}"
|
||||
echo "Published Cline Code desktop v${VERSION}"
|
||||
echo "Release: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${TAG}"
|
||||
echo "Auto-update feed refreshed: https://github.com/${GITHUB_REPOSITORY}/releases/download/${FEED}/latest.json"
|
||||
|
||||
@@ -912,16 +612,16 @@ jobs:
|
||||
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
|
||||
payload: |
|
||||
channel: "C0APVKGGZFC"
|
||||
text: "Cline desktop v${{ needs.validate.outputs.version }}${{ needs.validate.outputs.channel == 'beta' && ' (beta)' || '' }}"
|
||||
text: "Cline Code desktop v${{ needs.validate.outputs.version }}${{ needs.validate.outputs.channel == 'beta' && ' (beta)' || '' }}"
|
||||
blocks:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: "Cline desktop v${{ needs.validate.outputs.version }}${{ needs.validate.outputs.channel == 'beta' && ' (beta)' || '' }}"
|
||||
text: "Cline Code desktop v${{ needs.validate.outputs.version }}${{ needs.validate.outputs.channel == 'beta' && ' (beta)' || '' }}"
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.slack_content) }}
|
||||
text: ${{ toJSON(steps.changelog.outputs.content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
name: desktop-test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- desktop-experimental
|
||||
paths:
|
||||
- "apps/examples/desktop-app/package.json"
|
||||
- "apps/examples/desktop-app/scripts/dmg-background.ts"
|
||||
- "apps/examples/desktop-app/scripts/dmg-background.test.ts"
|
||||
- "apps/examples/desktop-app/src-tauri/dmg/background.png"
|
||||
- "apps/examples/desktop-app/src-tauri/dmg/background@2x.png"
|
||||
- ".github/workflows/desktop-test.yml"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- desktop-experimental
|
||||
paths:
|
||||
- "apps/examples/desktop-app/package.json"
|
||||
- "apps/examples/desktop-app/scripts/dmg-background.ts"
|
||||
- "apps/examples/desktop-app/scripts/dmg-background.test.ts"
|
||||
- "apps/examples/desktop-app/src-tauri/dmg/background.png"
|
||||
- "apps/examples/desktop-app/src-tauri/dmg/background@2x.png"
|
||||
- ".github/workflows/desktop-test.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
dmg-background:
|
||||
name: Test DMG background tooling
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/examples/desktop-app
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.13"
|
||||
|
||||
# The suite only uses Bun/Node built-ins and committed artwork, so it does
|
||||
# not need a workspace dependency install or macOS runner.
|
||||
- name: Test DMG background tooling
|
||||
run: bun run test:dmg-background
|
||||
@@ -23,6 +23,11 @@ on:
|
||||
required: true
|
||||
default: "main"
|
||||
type: string
|
||||
legacy-ref:
|
||||
description: "Ref to build the legacy bundle from"
|
||||
required: true
|
||||
default: "legacy-extension"
|
||||
type: string
|
||||
publish:
|
||||
description: "Publish to the VS Code Marketplace and Open VSX (unchecked: just build the .vsix artifact)"
|
||||
required: true
|
||||
@@ -125,37 +130,31 @@ jobs:
|
||||
name: Test legacy bundle
|
||||
runs-on: ubuntu-latest
|
||||
# The tested revision, exported so the build job builds EXACTLY what
|
||||
# this suite ran against. legacy-extension is a mutable branch name and
|
||||
# the build job starts later — re-resolving the name there could pick
|
||||
# up commits this gate never saw.
|
||||
# this suite ran against. legacy-ref is a mutable branch name and the
|
||||
# build job starts later — re-resolving the name there could pick up
|
||||
# commits this gate never saw.
|
||||
outputs:
|
||||
tested-sha: ${{ steps.rev.outputs.sha }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/vscode
|
||||
steps:
|
||||
# Always the protected legacy-extension branch — deliberately not
|
||||
# an input. An arbitrary ref here would be built into the published
|
||||
# VSIX by the environment-less build job, and the publish
|
||||
# environment approver only ever sees an opaque prebuilt artifact:
|
||||
# the approval would protect the marketplace PAT but not the
|
||||
# shipped bytes. Hardcoding the branch makes its protection rules
|
||||
# load-bearing for releases. Legacy hotfix testing has its own
|
||||
# workflow (ext-vscode-publish-legacy.yml).
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: legacy-extension
|
||||
ref: ${{ github.event.inputs.legacy-ref }}
|
||||
|
||||
- name: Record tested revision
|
||||
id: rev
|
||||
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Deliberately no dependency cache here: publish workflows do clean
|
||||
# installs and should not restore actions caches.
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
cache-dependency-path: |
|
||||
apps/vscode/package-lock.json
|
||||
apps/vscode/webview-ui/package-lock.json
|
||||
|
||||
- name: Install extension dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
@@ -490,35 +489,6 @@ jobs:
|
||||
echo "CHANGELOG_EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Slack section blocks reject text longer than 3000 characters, and
|
||||
# the Slack action logs that rejection WITHOUT failing the step - so
|
||||
# an over-long changelog silently drops the release announcement
|
||||
# while the run stays green. Post a trimmed copy to Slack and link
|
||||
# out to the full notes. The GitHub release body stays whole.
|
||||
RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/v${{ github.event.inputs.version }}"
|
||||
SLACK_CONTENT=$(CONTENT="$CONTENT" RELEASE_URL="$RELEASE_URL" python3 -c '
|
||||
import os
|
||||
content = os.environ["CONTENT"]
|
||||
more = "\n\n… <%s|Read the full release notes>" % os.environ["RELEASE_URL"]
|
||||
if len(content) <= 3000:
|
||||
print(content, end="")
|
||||
else:
|
||||
budget = 3000 - len(more)
|
||||
kept, used = [], 0
|
||||
for line in content.splitlines(keepends=True):
|
||||
if used + len(line) > budget:
|
||||
break
|
||||
kept.append(line)
|
||||
used += len(line)
|
||||
body = "".join(kept).rstrip() if kept else content[:budget].rstrip()
|
||||
print(body + more, end="")
|
||||
')
|
||||
{
|
||||
echo "slack_content<<CHANGELOG_EOF"
|
||||
echo "$SLACK_CONTENT"
|
||||
echo "CHANGELOG_EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Resolve previous release tag
|
||||
id: prev_tag
|
||||
if: ${{ !cancelled() && steps.publish_marketplace.outcome == 'success' }}
|
||||
@@ -574,7 +544,7 @@ jobs:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.slack_content) }}
|
||||
text: ${{ toJSON(steps.changelog.outputs.content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
|
||||
@@ -21,17 +21,20 @@ on:
|
||||
options:
|
||||
- pre-release
|
||||
- release
|
||||
branch:
|
||||
description: "Branch holding the legacy extension code"
|
||||
required: true
|
||||
default: "legacy-extension"
|
||||
type: string
|
||||
|
||||
# Read-only by default. The publish job elevates itself to contents: write for
|
||||
# the tag push and GitHub release; nothing here needs packages/checks/PR
|
||||
# write. Keeping the default minimal matters doubly in this workflow because
|
||||
# the test job runs BEFORE any environment approval — it must never hold a
|
||||
# write token while executing checked-out code.
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
packages: write
|
||||
checks: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: ext-vscode-publish-legacy
|
||||
group: ext-vscode-publish-legacy-${{ github.event.inputs.branch }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
@@ -47,23 +50,18 @@ jobs:
|
||||
run:
|
||||
working-directory: apps/vscode
|
||||
steps:
|
||||
# Always the protected legacy-extension branch — deliberately not
|
||||
# an input. This job runs full npm lifecycle scripts from the
|
||||
# checked-out code with no environment approval, and the publish
|
||||
# job below does the same next to the marketplace PATs; an
|
||||
# arbitrary ref here would hand both of them attacker-controlled
|
||||
# code. Hardcoding the branch makes its protection rules
|
||||
# load-bearing for releases.
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: legacy-extension
|
||||
ref: ${{ github.event.inputs.branch }}
|
||||
|
||||
# Deliberately no dependency cache here: publish workflows do clean
|
||||
# installs and should not restore actions caches.
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
cache-dependency-path: |
|
||||
apps/vscode/package-lock.json
|
||||
apps/vscode/webview-ui/package-lock.json
|
||||
|
||||
- name: Install extension dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
@@ -101,20 +99,16 @@ jobs:
|
||||
name: Publish Legacy Extension
|
||||
runs-on: ubuntu-latest
|
||||
environment: publish
|
||||
# For the tag push in Resolve Release Tag and the GitHub release.
|
||||
permissions:
|
||||
contents: write
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/vscode
|
||||
|
||||
steps:
|
||||
# Check out the legacy branch (NOT main; hardcoded — see the test
|
||||
# job's checkout comment). fetch-depth: 0 + tags so we can
|
||||
# create/push the release tag and compute the previous tag.
|
||||
# Check out the legacy branch (NOT main). fetch-depth: 0 + tags so we
|
||||
# can create/push the release tag and compute the previous tag.
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: legacy-extension
|
||||
ref: ${{ github.event.inputs.branch }}
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
lfs: true
|
||||
@@ -123,7 +117,7 @@ jobs:
|
||||
id: resolve_tag
|
||||
working-directory: ${{ github.workspace }}
|
||||
env:
|
||||
BRANCH: legacy-extension
|
||||
BRANCH: ${{ github.event.inputs.branch }}
|
||||
run: |
|
||||
# Tag is derived from the package version on the legacy branch.
|
||||
VERSION=$(node -p "require('./apps/vscode/package.json').version")
|
||||
@@ -264,33 +258,6 @@ jobs:
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
# Slack section blocks reject text longer than 3000 characters, and
|
||||
# the Slack action logs that rejection WITHOUT failing the step - so
|
||||
# an over-long changelog silently drops the release announcement
|
||||
# while the run stays green. Post a trimmed copy to Slack and link
|
||||
# out to the full notes. The GitHub release body stays whole.
|
||||
RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${{ steps.resolve_tag.outputs.tag }}"
|
||||
SLACK_CONTENT=$(CONTENT="$CONTENT" RELEASE_URL="$RELEASE_URL" python3 -c '
|
||||
import os
|
||||
content = os.environ["CONTENT"]
|
||||
more = "\n\n… <%s|Read the full release notes>" % os.environ["RELEASE_URL"]
|
||||
if len(content) <= 3000:
|
||||
print(content, end="")
|
||||
else:
|
||||
budget = 3000 - len(more)
|
||||
kept, used = [], 0
|
||||
for line in content.splitlines(keepends=True):
|
||||
if used + len(line) > budget:
|
||||
break
|
||||
kept.append(line)
|
||||
used += len(line)
|
||||
body = "".join(kept).rstrip() if kept else content[:budget].rstrip()
|
||||
print(body + more, end="")
|
||||
')
|
||||
echo "slack_content<<SLACK_EOF" >> $GITHUB_OUTPUT
|
||||
echo "$SLACK_CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "SLACK_EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
@@ -320,7 +287,7 @@ jobs:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.slack_content) }}
|
||||
text: ${{ toJSON(steps.changelog.outputs.content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
|
||||
@@ -14,12 +14,9 @@ name: ext-vscode-publish-nightly
|
||||
# pre-release publishes.
|
||||
|
||||
on:
|
||||
# Manual dispatch only. The nightly cron was removed deliberately: the
|
||||
# PublishNightly environment gained required reviewers, and an unattended
|
||||
# cron run would just sit `waiting` on that approval, hold this workflow's
|
||||
# concurrency group, and silently cancel every later scheduled run behind it
|
||||
# (that is exactly what happened between 2026-07-31 and 2026-08-21, killing
|
||||
# 20 consecutive nightlies). Cut a nightly by dispatching this workflow.
|
||||
schedule:
|
||||
# Every day at 4:00 AM PST (12:00 UTC)
|
||||
- cron: "0 12 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
legacy-ref:
|
||||
@@ -77,9 +74,8 @@ jobs:
|
||||
- name: Checkout legacy source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# NOTE: the || fallback is retained so this stays correct if a
|
||||
# non-dispatch trigger is ever added back (inputs are empty strings
|
||||
# on e.g. `schedule` events, where the declared default does not apply).
|
||||
# NOTE: inputs are empty strings on `schedule` events, so the ||
|
||||
# fallback (not the input's declared default) is what the cron uses.
|
||||
ref: ${{ inputs.legacy-ref || 'legacy-extension' }}
|
||||
path: legacy-src
|
||||
lfs: true
|
||||
|
||||
@@ -234,33 +234,6 @@ jobs:
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
# Slack section blocks reject text longer than 3000 characters, and
|
||||
# the Slack action logs that rejection WITHOUT failing the step - so
|
||||
# an over-long changelog silently drops the release announcement
|
||||
# while the run stays green. Post a trimmed copy to Slack and link
|
||||
# out to the full notes. The GitHub release body stays whole.
|
||||
RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${{ steps.resolve_tag.outputs.tag }}"
|
||||
SLACK_CONTENT=$(CONTENT="$CONTENT" RELEASE_URL="$RELEASE_URL" python3 -c '
|
||||
import os
|
||||
content = os.environ["CONTENT"]
|
||||
more = "\n\n… <%s|Read the full release notes>" % os.environ["RELEASE_URL"]
|
||||
if len(content) <= 3000:
|
||||
print(content, end="")
|
||||
else:
|
||||
budget = 3000 - len(more)
|
||||
kept, used = [], 0
|
||||
for line in content.splitlines(keepends=True):
|
||||
if used + len(line) > budget:
|
||||
break
|
||||
kept.append(line)
|
||||
used += len(line)
|
||||
body = "".join(kept).rstrip() if kept else content[:budget].rstrip()
|
||||
print(body + more, end="")
|
||||
')
|
||||
echo "slack_content<<SLACK_EOF" >> $GITHUB_OUTPUT
|
||||
echo "$SLACK_CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "SLACK_EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Package and Publish Extension
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
@@ -330,7 +303,7 @@ jobs:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.slack_content) }}
|
||||
text: ${{ toJSON(steps.changelog.outputs.content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
|
||||
@@ -80,9 +80,8 @@ jobs:
|
||||
include: ${{ fromJson(needs.matrix_prep.outputs.matrix) }}
|
||||
runs-on: ${{ matrix.runner }}-latest
|
||||
timeout-minutes: 20
|
||||
# Nothing in this job uses OIDC, so it does not need an id-token
|
||||
# permission.
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
@@ -94,9 +93,6 @@ jobs:
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
|
||||
# Cache keys below are exact-match only (no restore-keys prefix
|
||||
# fallbacks); a miss just means a cold install, which is acceptable.
|
||||
|
||||
# Cache Bun's global install cache - keyed on the authoritative root bun.lock.
|
||||
- name: Cache Bun install cache
|
||||
uses: actions/cache@v4
|
||||
@@ -104,6 +100,8 @@ jobs:
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
|
||||
# Cache VS Code installation
|
||||
- name: Cache VS Code
|
||||
@@ -112,6 +110,8 @@ jobs:
|
||||
with:
|
||||
path: apps/vscode/.vscode-test
|
||||
key: vscode-${{ runner.os }}-stable-${{ hashFiles('apps/vscode/.vscode-test.mjs', 'apps/vscode/package.json') }}
|
||||
restore-keys: |
|
||||
vscode-${{ runner.os }}-stable-
|
||||
|
||||
# Cache Playwright browsers
|
||||
- name: Cache Playwright browsers
|
||||
@@ -123,6 +123,8 @@ jobs:
|
||||
~/Library/Caches/ms-playwright
|
||||
~/AppData/Local/ms-playwright
|
||||
key: playwright-browsers-${{ runner.os }}-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
playwright-browsers-${{ runner.os }}-
|
||||
|
||||
# Single root install resolves the entire bun workspace at once (replaces
|
||||
# the per-package `npm ci` steps for apps/vscode + webview-ui).
|
||||
@@ -169,12 +171,9 @@ jobs:
|
||||
if: matrix.runner != 'ubuntu'
|
||||
run: bun run test:e2e:optimal
|
||||
|
||||
# Repo-root relative: the job's `working-directory` default applies to `run`
|
||||
# steps only, so an apps/vscode-relative path here silently matches nothing
|
||||
# and every failing run uploads no recordings at all.
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ failure() }}
|
||||
with:
|
||||
name: playwright-recordings-${{ matrix.runner }}
|
||||
path: |
|
||||
apps/vscode/test-results/
|
||||
test-results/playwright/
|
||||
|
||||
@@ -282,33 +282,6 @@ jobs:
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "${DELIMITER}" >> $GITHUB_OUTPUT
|
||||
|
||||
# Slack section blocks reject text longer than 3000 characters, and the
|
||||
# Slack action logs that rejection WITHOUT failing the step - so an
|
||||
# over-long changelog silently drops the release announcement while the
|
||||
# run stays green. Post a trimmed copy to Slack and link out to the full
|
||||
# notes. The GitHub release body stays whole.
|
||||
RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/sdk/sdk/v${{ steps.version.outputs.version }}"
|
||||
SLACK_CONTENT=$(CONTENT="$CONTENT" RELEASE_URL="$RELEASE_URL" python3 -c '
|
||||
import os
|
||||
content = os.environ["CONTENT"]
|
||||
more = "\n\n… <%s|Read the full release notes>" % os.environ["RELEASE_URL"]
|
||||
if len(content) <= 3000:
|
||||
print(content, end="")
|
||||
else:
|
||||
budget = 3000 - len(more)
|
||||
kept, used = [], 0
|
||||
for line in content.splitlines(keepends=True):
|
||||
if used + len(line) > budget:
|
||||
break
|
||||
kept.append(line)
|
||||
used += len(line)
|
||||
body = "".join(kept).rstrip() if kept else content[:budget].rstrip()
|
||||
print(body + more, end="")
|
||||
')
|
||||
echo "slack_content<<${DELIMITER}" >> $GITHUB_OUTPUT
|
||||
echo "$SLACK_CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "${DELIMITER}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
|
||||
uses: softprops/action-gh-release@v1
|
||||
@@ -360,7 +333,7 @@ jobs:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.slack_content) }}
|
||||
text: ${{ toJSON(steps.changelog.outputs.content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
|
||||
@@ -46,12 +46,6 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
# @cline/ui imports @cline/shared/browser (generated-media), which
|
||||
# resolves to dist output — build it before anything typechecks or
|
||||
# builds the ui package.
|
||||
- name: Build shared package
|
||||
run: bun -F @cline/shared build
|
||||
|
||||
- name: Typecheck UI
|
||||
run: bun -F @cline/ui typecheck
|
||||
|
||||
@@ -64,6 +58,11 @@ jobs:
|
||||
- name: Build UI package
|
||||
run: bun -F @cline/ui build
|
||||
|
||||
# The desktop chat test imports @cline/shared/browser, which resolves to
|
||||
# dist output that nothing else in this job builds.
|
||||
- name: Build shared package
|
||||
run: bun -F @cline/shared build
|
||||
|
||||
- name: Test desktop chat integration
|
||||
run: bun -F @cline/code test:chat-ui
|
||||
|
||||
|
||||
@@ -88,7 +88,6 @@ apps/vscode/tsconfig.test.generated.json
|
||||
.next/dev/static
|
||||
**/src-tauri/target/debug/.fingerprint
|
||||
apps/examples/desktop-app/src-tauri/target
|
||||
apps/examples/desktop-app/src-tauri/dmg/background.gen.tiff
|
||||
apps/examples/desktop-app/webview/.next
|
||||
|
||||
# Next.js generated type shim (churns between dev and build)
|
||||
|
||||
@@ -1,99 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## [4.1.16]
|
||||
|
||||
Everything here lands through the SDK bundle, so it applies to windows running that bundle.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Cost estimates are no longer shown for providers billed by a flat-rate subscription (ClinePass, ChatGPT via Codex, and Claude Code). The task header and model pricing rows rendered API-rate dollar figures that read as real charges on top of the subscription, including a flash of them on every chat-view mount while provider listings were loading.
|
||||
- Signing back in no longer moves your last-used provider off ClinePass on credential refresh.
|
||||
- Hooks now resolve their workspace from the VS Code window instead of shared global state in `~/.cline`. With a second window open on another project, a workspace's `.clinerules/hooks` scripts were never discovered, and hook cwd and the workspace paths passed to hook scripts resolved against whatever project some other or older Cline instance last recorded.
|
||||
- New files are now created with your platform's native line endings.
|
||||
- Fixed the codebase search tool crashing on files containing a single enormous line.
|
||||
- Credentials embedded in git remote URLs are now redacted from the workspace information sent to the model, which also now carries richer workspace metadata.
|
||||
- Installing an MCP server from the marketplace no longer misreads the catalog's `--` separator as part of the server command.
|
||||
- The hub's event log can no longer grow until it fills your disk.
|
||||
|
||||
### Changed
|
||||
|
||||
- The per-tool MCP auto-approve checkboxes are hidden. MCP auto-approval is governed solely by the global "Use MCP servers" toggle — the per-tool checkboxes were no-ops that implied granularity the approval path does not have.
|
||||
|
||||
## [4.1.15]
|
||||
|
||||
Everything here lands through the SDK bundle, so it applies to windows running that bundle.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Auto-approve every MCP tool call while the "Use MCP servers" toggle is on. The toggle only took effect on tools that had also been opted in individually, so turning it on appeared to do nothing; it now governs all MCP tools on its own.
|
||||
|
||||
## [4.1.14]
|
||||
|
||||
Everything here lands through the SDK bundle, so it applies to windows running that bundle.
|
||||
|
||||
### Added
|
||||
|
||||
- Refresh the built-in model catalog. New entries include Claude Fable 5, Grok 4.6 on Vertex, several DeepSeek V4 Flash variants (including the vision preview), MiMo v2.5, Qwen3.8 27B, Gemma 4 26B, LongCat 2.0, Nemotron 3.5 Lightning, and Thinking Machines' Inkling models.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Restore task completion telemetry for interactive sessions. A share of interactive stops routed through a teardown path that never reported completion after 4.1.11 changed how session status is tracked; every session now reports it exactly once.
|
||||
|
||||
## [4.1.13]
|
||||
|
||||
Everything here lands through the SDK bundle, so it applies to windows running that bundle.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Restore tool calling for custom OpenAI-Compatible models whose capability list was inferred from convenience flags like `supportsReasoning`. The inferred list read as an authoritative denial and stripped every tool from the request; an explicitly authored capability list still decides.
|
||||
- Keep Hub-backed sessions intact across a Hub restart or upgrade. Clients replay the events they missed while disconnected, and the same event is no longer delivered twice when the replay and live streams overlap.
|
||||
- Carry session and client identity into Langfuse traces for Hub-backed and delegated-agent runs, which previously arrived without their session grouping or client version.
|
||||
|
||||
## [4.1.12]
|
||||
|
||||
Everything here lands through the SDK bundle, so it applies to windows running that bundle.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Enforce enterprise MCP controls on the Customize marketplace. MCP entries are now hidden when remote config disables the marketplace, and limited to `allowedMCPServers` when an allowlist is configured.
|
||||
- Restore tool calling for custom OpenAI-Compatible models whose stored capability list was empty.
|
||||
|
||||
## [4.1.11]
|
||||
|
||||
Everything here lands through the SDK bundle, so it applies to windows running that bundle — except the last section, which is a legacy-bundle fix.
|
||||
|
||||
### Added
|
||||
|
||||
- Let models that support it generate images during a task. Generated images render inline in the conversation.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix code actions failing with "command not found" on VS Code 1.134.
|
||||
- Fix `@` file mentions breaking on paths that contain spaces.
|
||||
- Show the diff edit view for multi-line edits in files with CRLF line endings.
|
||||
- Continue the surviving session when resuming a task, instead of rebuilding it from the original task text.
|
||||
- Clear the task-scoped settings overlay when the task view is cleared or switched, so one task's overrides no longer leak into the next.
|
||||
- Honor the classic truncation range when migrating legacy tasks.
|
||||
- Preserve LiteLLM input token limits instead of overwriting them with catalog values.
|
||||
- Restore custom base URLs for Gemini, and normalize legacy host-root values so they keep working.
|
||||
- Point provider signup links at each provider's API key page instead of a generic landing page.
|
||||
- Load skill slash commands through the skills tool instead of pasting their instructions into your message, which previously delivered them twice.
|
||||
- Stop offering image, voice, and other non-chat models in chat model pickers.
|
||||
- Deliver a `PreToolUse` hook's `contextModification` to the model again, and wait for `PostToolUse` hooks so their output and `cancel` control are honored.
|
||||
- Show tool activity a provider runs itself — every tool the Claude Code provider executes inside its own session — instead of dropping it from the conversation.
|
||||
- Fix `run_commands` failing with ENOENT when a structured command carried a full command line with no arguments.
|
||||
- Run PowerShell commands fail-fast, so a pipeline erroring per item stops at the first error instead of flooding output and still reporting success.
|
||||
- Keep remote configuration in step with the SDK: coordinated refreshes, session gating, and a fail-closed opt-out.
|
||||
|
||||
### Changed
|
||||
|
||||
- Show the billed cost for Cline gateway usage.
|
||||
- Refresh the model catalog, which adds AMD, Arcee, Echo, Jalapeno, Kosmik, LLM Gateway, RunInfra, and SCNet as providers and updates model lists, pricing, and per-provider default models across the board.
|
||||
|
||||
### Fixed (legacy bundle)
|
||||
|
||||
- Only treat an Anthropic `invalid_request_error` as a context-overflow when its message says so. An unrelated invalid request (bad tool schema, oversized image, unknown model id) no longer triggers context-overflow recovery.
|
||||
|
||||
## [4.1.10]
|
||||
|
||||
Everything in this release lands through the SDK bundle, so it applies to windows running that bundle and not the legacy one. The legacy bundle is unchanged from 4.1.9.
|
||||
|
||||
@@ -1,67 +1,5 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.61
|
||||
|
||||
- Cline now handles a running Hub that is older than your CLI. Instead of quietly talking to a hub executing stale code, you get a prompt showing how many active sessions a replacement would interrupt, with enter-to-replace or escape-to-keep. The replacement drains the Hub first so in-flight turns finish, and a hub too old or wedged to accept the drain is left alone rather than killed
|
||||
- Windows binaries are now Authenticode-signed via Azure Trusted Signing, and a launch blocked by application-control policy now prints an actionable error instead of failing bare
|
||||
- Fixed the CLI dying when an enabled remote (SSE/streamable HTTP) MCP server is unreachable. The connect now has a 10s budget, so an offline server no longer stalls session startup past the Hub's deadline and tears the session down — previously the interactive TUI exited and one-shot runs failed
|
||||
- Fixed tool calling being silently disabled for Dify, SAP AI Core, opencode, and Codex CLI models. Their catalog entries declare no capabilities, and the empty list was read as an authoritative denial that stripped every tool from the request
|
||||
- Fixed images being dropped from file reads on models whose capability list is empty
|
||||
- Langfuse tracing now works in released builds. Detection identified the OpenTelemetry provider by class name, which minification renames, so tracing silently initialized as not-ready in every published binary while working in dev
|
||||
- Restoring a checkpoint now refuses to run when you have made commits after it, instead of silently knocking them off the branch where only the reflog could recover them. Chat-only restore is unaffected
|
||||
- `apply_patch` now preserves a file's existing CRLF line endings
|
||||
- Global rules are now also read from `~/Cline/Rules`, which is where the VS Code Rules tab writes them on WSL and headless installs
|
||||
- Signing in to OpenAI Codex (ChatGPT subscription) now fails with a clear "port in use" error when 1455 is occupied, instead of opening a browser to a flow that can never complete
|
||||
- A transient network failure while refreshing Codex or OpenAI-compatible-account tokens no longer logs you out
|
||||
- Aborting a session now also cancels the delegated subagents and teammates it spawned, instead of leaving their work running
|
||||
- Agent-created schedules now live in `~/.cline/schedules` instead of inheriting whichever chat folder they were created in. Schedules you create with `--workspace` are unchanged
|
||||
- Fixed scheduled tasks disappearing after a hub restart
|
||||
- Fixed markdown flashing as it settled at the end of a streamed response
|
||||
- The message the model sees when you reject a tool call now names the tool and reads as your decision rather than an error
|
||||
- Cline provider models now come from the live catalog, so newly published models show up without a CLI update
|
||||
- Refreshed the model catalog. Adds ten providers (Bothub, OpenReason, SenseNova (China), TokenGo, TokenRouter, Vancine, Volcengine Ark, Volcengine Ark Coding Plan, above.dev, and klokintegration.se) and updates model lists and pricing across providers. This is an unusually wide refresh: the resolved default model changes for 57 providers. Most consequentially, Anthropic now resolves to Claude Fable 5.1 instead of Claude Opus 5, and Amazon Bedrock, Vertex, OpenRouter, Vercel AI Gateway, Kilo Gateway, LLM Gateway, DevPass, DigitalOcean, CrossModel, Eden AI, and NanoGPT follow it to Fable 5.1. If you use any provider without pinning a model, expect a different default
|
||||
|
||||
## 3.0.60
|
||||
|
||||
- Fixed the background hub process ballooning in memory during long sessions — session status updates were broadcasting a full copy of the conversation transcript to every connected client, which on a large task could grow the process to tens of gigabytes. Upgrading retires the running hub so the fix takes effect on the next command
|
||||
- New files are now created with your platform's native line endings
|
||||
- Fixed the codebase search tool crashing on files that contain a single enormous line
|
||||
- Cost estimates are no longer shown for Claude Code. Its usage is typically covered by a Claude Pro/Max subscription, but its models reuse Anthropic API pricing, so Cline was showing charges you were not being billed
|
||||
- Credentials embedded in git remote URLs are now redacted from the workspace information sent to the model
|
||||
- Installing an MCP server no longer misreads a `--` separator in the install arguments as part of the server command
|
||||
- Refreshed the model catalog. Adds seven providers (Agnes AI, Aixy, IteraCompute, LLM Tech, NeoSmith, Pendra, and Standard Compute) and updates model lists and pricing across providers. The resolved default model changes for ClinePass (now GLM 5.3), Z.ai, Hugging Face, evroc, LLM Gateway, NanoGPT, and Weights & Biases, so if you use one of those without pinning a model you will get a different default
|
||||
|
||||
## 3.0.58
|
||||
|
||||
- The first-launch "Try ClinePass" dialog no longer advertises the $4.99 first-month promo, which is ending
|
||||
- The hub's event log is now capped at 64 MiB on disk. Events carrying full session snapshots could previously grow the log to tens of gigabytes on a long-running hub, since deleting rows never shrinks the file. Oldest events are dropped first and the space is returned, and pruning runs on volume as well as on a timer
|
||||
- Refreshed the model catalog. Adds two providers (AgentRouter and Opper) and updates model lists and pricing across providers. The resolved default model changes for Aki.io and NanoGPT, so if you use one of those without pinning a model you will get a different default
|
||||
|
||||
## 3.0.57
|
||||
|
||||
- Added `cline hub drain`, which stops a hub from accepting new mutating work while it finishes what it is already running, and `cline hub drain --off` to lift it
|
||||
- Added `cline hub upgrade`, which drains the hub, waits for it to go idle, stops it, and starts a fresh one on the current build. An aborted upgrade lifts the drain again, so the hub is never left refusing work
|
||||
- Sessions now survive a hub restart. A reconnecting client replays the events it missed while disconnected, deduped by event id so nothing is delivered twice
|
||||
- Fixed tool calling being silently disabled for custom OpenAI-Compatible models whose capability list was inferred from convenience flags like `supportsReasoning`. The inferred list read as an authoritative denial and stripped every tool from the request
|
||||
- Langfuse traces now carry session and client identity for hub-backed and delegated-agent runs, instead of arriving without their session grouping or client version
|
||||
- Refreshed the model catalog, which updates model lists and pricing across providers and changes the resolved default model for several of them (DeepSeek, Crof, CrossModel, Eden AI, Kilo, and NanoGPT)
|
||||
|
||||
## 3.0.56
|
||||
|
||||
- Models that support image generation can now produce media during a turn. The TUI saves each generated file to a temporary path and prints it so you can open it with your usual tools, HTML session exports embed images inline, and ACP clients receive generated images as image content
|
||||
- Skill slash commands now load through the skills tool instead of expanding into your message. History and resume show the `/command` you typed instead of the whole skill body, and the instructions reach the model once instead of twice. Workflows still expand, as does zen mode, whose preset has no skills tool
|
||||
- Image, voice, and other non-chat models are no longer offered in the onboarding and model pickers or ACP model listings, and are rejected for `--model`
|
||||
- Fixed TUI dialog colors not following theme changes live
|
||||
- Fixed the account dialog's selection chevron so it matches the other dialogs
|
||||
- Fixed provider-executed tool activity — every tool the Claude Code provider runs inside its own session — being dropped instead of shown as a tool card
|
||||
- Fixed `PreToolUse` hook `contextModification` never reaching the model, and `PostToolUse` hooks running fire-and-forget with their output and `cancel` control discarded
|
||||
- Fixed `run_commands` failing with ENOENT when a structured command carried a full command line with no `args`
|
||||
- PowerShell commands now fail fast on the first error instead of emitting an error record per enumerated item and still reporting success
|
||||
- Fixed Gemini custom base URLs configured as a host root
|
||||
- Fixed `cline schedule` commands against a remote hub, which now register a workspace client so they are authorized under the new workspace-scoped schedule rules
|
||||
- Usage now displays the billed gateway cost
|
||||
- Refreshed the model catalog, which adds AMD, Arcee, Echo, Jalapeno, Kosmik, LLM Gateway, RunInfra, and SCNet as providers and updates model lists, pricing, and per-provider default models across the board
|
||||
|
||||
## 3.0.55
|
||||
|
||||
- Auto-updates no longer install while a CLI is attached to the Hub. The update is recorded at startup and installed on exit, once the Hub confirms nothing else is attached, so a background update can no longer swap the package out from under a live session and kill it with `Hub connection closed (code=1006)`. `cline update` still installs immediately and now tells you the update applies on next start
|
||||
|
||||
@@ -270,9 +270,6 @@ The postinstall script runs in diverse environments (CI, Docker, restricted perm
|
||||
### Windows
|
||||
Windows binaries are `.exe` files. The build script appends `.exe` to the output filename on Windows targets. The resolver handles this. npm on Windows generates `.cmd` shims for bin entries automatically.
|
||||
|
||||
### Windows code signing
|
||||
Windows application control (Smart App Control, WDAC, AppLocker) blocks unsigned executables at launch, regardless of how they were installed — npm distribution gets no exemption ([#12934](https://github.com/cline/cline/issues/12934)). The publish workflow Authenticode-signs `cli-windows-x64/bin/cline.exe` and `cli-windows-arm64/bin/cline.exe` with Azure Trusted Signing before publishing, via the `.github/actions/sign-windows-cli` composite action. Signing runs on the Linux publish runner using [jsign](https://ebourg.github.io/jsign/) (`--storetype TRUSTEDSIGNING`) with an OIDC-federated Entra app, then verifies the signature chain with `osslsigncode` against the Microsoft Identity Verification Root CA 2020. If all `AZURE_*` / `AZURE_TRUSTED_SIGNING_*` repository secrets are absent, the action logs a warning and the release ships unsigned rather than failing; if only some resolve (a typo'd or renamed secret), the release fails loudly instead. The certificate profile secret is suffixed `_CLI` because the desktop app will later get its own profile; the other five secrets are shared. Note that signing bun-compiled executables requires Bun >= 1.2.23 (earlier versions located the embedded bundle relative to the end of the file, which signing corrupts).
|
||||
|
||||
### File permissions
|
||||
Compiled binaries need to be executable (`chmod 755`). The build script sets this after copying. The postinstall also sets permissions on the cached binary. Some npm packaging steps can strip permissions, so both handle this defensively.
|
||||
|
||||
|
||||
@@ -72,29 +72,6 @@ function run(target) {
|
||||
});
|
||||
if (result.error) {
|
||||
console.error(result.error.message);
|
||||
// Windows application control (Smart App Control, WDAC, AppLocker)
|
||||
// blocks the child exe at launch, which Node surfaces only as an
|
||||
// opaque "spawnSync ... UNKNOWN" error. Point users at the real cause.
|
||||
const code = result.error.code;
|
||||
if (
|
||||
os.platform() === "win32" &&
|
||||
(code === "UNKNOWN" || code === "EACCES" || code === "EPERM")
|
||||
) {
|
||||
console.error(
|
||||
"\nWindows refused to start the Cline binary:\n " +
|
||||
target +
|
||||
"\n\n" +
|
||||
"This usually means an application control policy (Smart App Control,\n" +
|
||||
"WDAC, or AppLocker) or antivirus blocked the executable. To confirm,\n" +
|
||||
"run the path above directly in a terminal and check the error Windows\n" +
|
||||
"reports, or inspect its signature with:\n\n" +
|
||||
' Get-AuthenticodeSignature "' +
|
||||
target +
|
||||
'"\n\n' +
|
||||
"If it was blocked by policy, allow the file or ask your administrator\n" +
|
||||
"to trust it. See https://github.com/cline/cline/issues for known issues.",
|
||||
);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
if (typeof result.status === "number") {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.61",
|
||||
"version": "3.0.55",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -70,10 +70,6 @@ import {
|
||||
sendSessionInfoUpdate,
|
||||
} from "./session-updates";
|
||||
|
||||
const CHAT_MODEL_QUERY_OPTIONS = {
|
||||
filter: "chat",
|
||||
} satisfies Llms.GetModelsForProviderOptions;
|
||||
|
||||
interface SessionState {
|
||||
id: string;
|
||||
cwd: string;
|
||||
@@ -189,10 +185,7 @@ export class AcpAgent implements Agent {
|
||||
const providerId =
|
||||
process.env.CLINE_PROVIDER ?? this.authResult?.providerId ?? "cline";
|
||||
|
||||
const providerModels = await Llms.getModelsForProvider(
|
||||
providerId,
|
||||
CHAT_MODEL_QUERY_OPTIONS,
|
||||
);
|
||||
const providerModels = await Llms.getModelsForProvider(providerId);
|
||||
// Model ids are provider-scoped, so the default must come from the
|
||||
// provider's own catalog: `cline-pass` uses `cline-pass/…` ids that mean
|
||||
// nothing to `cline`, and vice versa.
|
||||
@@ -263,10 +256,7 @@ export class AcpAgent implements Agent {
|
||||
// provider's own catalog just like newSession.
|
||||
const providerId =
|
||||
process.env.CLINE_PROVIDER ?? this.authResult?.providerId ?? "cline";
|
||||
const providerModels = await Llms.getModelsForProvider(
|
||||
providerId,
|
||||
CHAT_MODEL_QUERY_OPTIONS,
|
||||
);
|
||||
const providerModels = await Llms.getModelsForProvider(providerId);
|
||||
session = {
|
||||
id: params.sessionId,
|
||||
cwd: params.cwd,
|
||||
@@ -299,7 +289,6 @@ export class AcpAgent implements Agent {
|
||||
|
||||
const providerModels = await Llms.getModelsForProvider(
|
||||
session.currentProviderId,
|
||||
CHAT_MODEL_QUERY_OPTIONS,
|
||||
);
|
||||
const availableModels = Object.entries(providerModels).map(
|
||||
([availableModelId, info]) => ({
|
||||
@@ -484,10 +473,7 @@ export class AcpAgent implements Agent {
|
||||
// current one when it's offered there too, otherwise fall back to the
|
||||
// provider's declared default rather than whichever model happens to
|
||||
// be listed first (for cline-pass that is an unrelated free model).
|
||||
const providerModels = await Llms.getModelsForProvider(
|
||||
value,
|
||||
CHAT_MODEL_QUERY_OPTIONS,
|
||||
);
|
||||
const providerModels = await Llms.getModelsForProvider(value);
|
||||
session.currentModelId = await resolveDefaultModelId(
|
||||
value,
|
||||
session.currentModelId,
|
||||
@@ -921,10 +907,7 @@ async function buildAllConfigOptions(
|
||||
): Promise<SessionConfigOption[]> {
|
||||
const [providerOption, providerModels] = await Promise.all([
|
||||
buildProviderConfigOption(session.currentProviderId),
|
||||
Llms.getModelsForProvider(
|
||||
session.currentProviderId,
|
||||
CHAT_MODEL_QUERY_OPTIONS,
|
||||
),
|
||||
Llms.getModelsForProvider(session.currentProviderId),
|
||||
]);
|
||||
return [
|
||||
providerOption,
|
||||
|
||||
@@ -17,7 +17,6 @@ const mocks = vi.hoisted(() => ({
|
||||
stopConnectorsViaHub: vi.fn(async () => undefined as number | undefined),
|
||||
disableConnectorAutostart: vi.fn(),
|
||||
getPersistedConnectorConnection: vi.fn(),
|
||||
getProcessStartToken: vi.fn(() => undefined),
|
||||
getConnector: vi.fn(),
|
||||
listActiveConnectors: vi.fn(),
|
||||
listConnectors: vi.fn((): Array<{ name: string; description: string }> => []),
|
||||
@@ -30,7 +29,6 @@ const mocks = vi.hoisted(() => ({
|
||||
vi.mock("@cline/core", () => ({
|
||||
disableConnectorAutostart: mocks.disableConnectorAutostart,
|
||||
getPersistedConnectorConnection: mocks.getPersistedConnectorConnection,
|
||||
getProcessStartToken: mocks.getProcessStartToken,
|
||||
listActiveConnectors: mocks.listActiveConnectors,
|
||||
persistConnectorConnection: mocks.persistConnectorConnection,
|
||||
removePersistedConnectorConnection: mocks.removePersistedConnectorConnection,
|
||||
|
||||
@@ -3,20 +3,16 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
const {
|
||||
mockClearHubDiscovery,
|
||||
mockEnsureDetachedHubServer,
|
||||
mockLocalHubHasNoActiveSessions,
|
||||
mockProbeHubServer,
|
||||
mockReadHubDiscovery,
|
||||
mockRequestHubDrain,
|
||||
mockResolveProductionHubOwnerContext,
|
||||
mockResolveSharedHubOwnerContext,
|
||||
mockStopLocalHubServerGracefully,
|
||||
} = vi.hoisted(() => ({
|
||||
mockClearHubDiscovery: vi.fn(),
|
||||
mockEnsureDetachedHubServer: vi.fn(),
|
||||
mockLocalHubHasNoActiveSessions: vi.fn(),
|
||||
mockProbeHubServer: vi.fn(),
|
||||
mockReadHubDiscovery: vi.fn(),
|
||||
mockRequestHubDrain: vi.fn(),
|
||||
mockResolveProductionHubOwnerContext: vi.fn(() => ({
|
||||
ownerId: "hub-production",
|
||||
discoveryPath: "/tmp/cline-data/locks/hub/production.json",
|
||||
@@ -31,10 +27,8 @@ const {
|
||||
vi.mock("@cline/core", () => ({
|
||||
clearHubDiscovery: mockClearHubDiscovery,
|
||||
ensureDetachedHubServer: mockEnsureDetachedHubServer,
|
||||
localHubHasNoActiveSessions: mockLocalHubHasNoActiveSessions,
|
||||
probeHubServer: mockProbeHubServer,
|
||||
readHubDiscovery: mockReadHubDiscovery,
|
||||
requestHubDrain: mockRequestHubDrain,
|
||||
resolveProductionHubOwnerContext: mockResolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
|
||||
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
|
||||
@@ -101,147 +95,6 @@ describe("createHubCommand", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function createCommand() {
|
||||
const output: string[] = [];
|
||||
const errors: string[] = [];
|
||||
let exitCode = 0;
|
||||
const cmd = createHubCommand(
|
||||
{
|
||||
writeln: (text) => {
|
||||
output.push(text ?? "");
|
||||
},
|
||||
writeErr: (text) => {
|
||||
errors.push(text);
|
||||
},
|
||||
},
|
||||
(code) => {
|
||||
exitCode = code;
|
||||
},
|
||||
);
|
||||
return {
|
||||
cmd,
|
||||
output,
|
||||
errors,
|
||||
exitCode: () => exitCode,
|
||||
};
|
||||
}
|
||||
|
||||
it("sends an un-drain request with drain --off", async () => {
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "token",
|
||||
});
|
||||
mockRequestHubDrain.mockResolvedValue(true);
|
||||
|
||||
const { cmd, output, exitCode } = createCommand();
|
||||
await cmd.parseAsync(["drain", "--off"], { from: "user" });
|
||||
|
||||
expect(exitCode()).toBe(0);
|
||||
expect(mockRequestHubDrain).toHaveBeenCalledWith(
|
||||
"ws://127.0.0.1:25463/hub",
|
||||
"token",
|
||||
"cline hub drain --off",
|
||||
{ off: true },
|
||||
);
|
||||
expect(JSON.parse(output[0] || "")).toEqual({
|
||||
draining: false,
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
});
|
||||
});
|
||||
|
||||
it("drains without the off flag by default", async () => {
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "token",
|
||||
});
|
||||
mockRequestHubDrain.mockResolvedValue(true);
|
||||
|
||||
const { cmd, output, exitCode } = createCommand();
|
||||
await cmd.parseAsync(["drain"], { from: "user" });
|
||||
|
||||
expect(exitCode()).toBe(0);
|
||||
expect(mockRequestHubDrain).toHaveBeenCalledWith(
|
||||
"ws://127.0.0.1:25463/hub",
|
||||
"token",
|
||||
"cline hub drain",
|
||||
{ off: false },
|
||||
);
|
||||
expect(JSON.parse(output[0] || "")).toEqual({
|
||||
draining: true,
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces an idle hub with upgrade --wait 0 instead of skipping the idle check", async () => {
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "token",
|
||||
});
|
||||
mockRequestHubDrain.mockResolvedValue(true);
|
||||
mockLocalHubHasNoActiveSessions.mockResolvedValue(true);
|
||||
mockStopLocalHubServerGracefully.mockResolvedValue(true);
|
||||
mockEnsureDetachedHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "new-token",
|
||||
});
|
||||
|
||||
const { cmd, output, errors, exitCode } = createCommand();
|
||||
await cmd.parseAsync(["upgrade", "--wait", "0"], { from: "user" });
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(exitCode()).toBe(0);
|
||||
expect(mockLocalHubHasNoActiveSessions).toHaveBeenCalled();
|
||||
expect(mockStopLocalHubServerGracefully).toHaveBeenCalled();
|
||||
expect(mockEnsureDetachedHubServer).toHaveBeenCalled();
|
||||
// The drain was never lifted manually: the drained hub was replaced.
|
||||
expect(mockRequestHubDrain).toHaveBeenCalledTimes(1);
|
||||
expect(JSON.parse(output[0] || "")).toEqual({
|
||||
upgraded: true,
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
});
|
||||
});
|
||||
|
||||
it("un-drains the hub when upgrade aborts because sessions are still active", async () => {
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "token",
|
||||
});
|
||||
mockRequestHubDrain.mockResolvedValue(true);
|
||||
mockLocalHubHasNoActiveSessions.mockResolvedValue(false);
|
||||
|
||||
const { cmd, errors, exitCode } = createCommand();
|
||||
await cmd.parseAsync(["upgrade", "--wait", "0"], { from: "user" });
|
||||
|
||||
expect(exitCode()).toBe(1);
|
||||
expect(errors[0]).toContain("still serving sessions");
|
||||
expect(mockStopLocalHubServerGracefully).not.toHaveBeenCalled();
|
||||
expect(mockEnsureDetachedHubServer).not.toHaveBeenCalled();
|
||||
expect(mockRequestHubDrain).toHaveBeenCalledTimes(2);
|
||||
expect(mockRequestHubDrain).toHaveBeenLastCalledWith(
|
||||
"ws://127.0.0.1:25463/hub",
|
||||
"token",
|
||||
"cline hub upgrade aborted",
|
||||
{ off: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a non-numeric upgrade --wait instead of treating it as an expired deadline", async () => {
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "token",
|
||||
});
|
||||
|
||||
const { cmd } = createCommand();
|
||||
cmd.configureOutput({ writeErr: () => {} });
|
||||
for (const sub of cmd.commands) {
|
||||
sub.configureOutput({ writeErr: () => {} });
|
||||
}
|
||||
await expect(
|
||||
cmd.parseAsync(["upgrade", "--wait", "soon"], { from: "user" }),
|
||||
).rejects.toThrow("--wait requires a non-negative number of seconds.");
|
||||
expect(mockRequestHubDrain).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes the selected owner to graceful stop", async () => {
|
||||
process.env.CLINE_BUILD_ENV = "development";
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import {
|
||||
clearHubDiscovery,
|
||||
ensureDetachedHubServer,
|
||||
localHubHasNoActiveSessions,
|
||||
probeHubServer,
|
||||
readHubDiscovery,
|
||||
requestHubDrain,
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
stopLocalHubServerGracefully,
|
||||
} from "@cline/core";
|
||||
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
|
||||
import { Command, InvalidArgumentError } from "commander";
|
||||
import { Command } from "commander";
|
||||
import { version as cliVersion } from "../../package.json";
|
||||
|
||||
interface HubCommandIo {
|
||||
@@ -56,16 +54,6 @@ function resolveCliHubOwnerContext() {
|
||||
: resolveSharedHubOwnerContext();
|
||||
}
|
||||
|
||||
function parseWaitSeconds(value: string): number {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (Number.isNaN(parsed) || parsed < 0) {
|
||||
throw new InvalidArgumentError(
|
||||
"--wait requires a non-negative number of seconds.",
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function createHubCommand(
|
||||
io: HubCommandIo,
|
||||
setExitCode: (code: number) => void,
|
||||
@@ -162,117 +150,5 @@ export function createHubCommand(
|
||||
}),
|
||||
);
|
||||
|
||||
hub
|
||||
.command("drain")
|
||||
.description("Refuse new mutating work while accepted runs finish")
|
||||
.option("--reason <text>", "Why the hub is draining")
|
||||
.option("--off", "Lift the drain and accept new mutating work again")
|
||||
.action(
|
||||
action(async (cmdOptions: { reason?: string; off?: boolean }) => {
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath);
|
||||
if (!discovery?.url) {
|
||||
io.writeErr("No hub is running.");
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
const draining = cmdOptions.off !== true;
|
||||
const ok = await requestHubDrain(
|
||||
discovery.url,
|
||||
discovery.authToken,
|
||||
cmdOptions.reason ??
|
||||
(draining ? "cline hub drain" : "cline hub drain --off"),
|
||||
{ off: !draining },
|
||||
);
|
||||
if (!ok) {
|
||||
io.writeErr(
|
||||
draining
|
||||
? "Hub drain request failed."
|
||||
: "Hub un-drain request failed.",
|
||||
);
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
io.writeln(JSON.stringify({ draining, url: discovery.url }));
|
||||
}),
|
||||
);
|
||||
|
||||
hub
|
||||
.command("upgrade")
|
||||
.description(
|
||||
"Drain, wait for the hub to go idle, stop it, and start a fresh one",
|
||||
)
|
||||
.option(
|
||||
"--wait <seconds>",
|
||||
"How long to wait for the hub to go idle",
|
||||
parseWaitSeconds,
|
||||
120,
|
||||
)
|
||||
.action(
|
||||
action(async (cmdOptions: { wait: number }) => {
|
||||
const opts = hub.opts<{
|
||||
cwd: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
pathname?: string;
|
||||
}>();
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath);
|
||||
if (discovery?.url) {
|
||||
const drained = await requestHubDrain(
|
||||
discovery.url,
|
||||
discovery.authToken,
|
||||
"cline hub upgrade",
|
||||
).catch(() => false);
|
||||
// An aborted upgrade must hand the hub back: leaving it
|
||||
// draining refuses all new mutating work until a restart.
|
||||
const undrain = async (): Promise<void> => {
|
||||
if (!drained) {
|
||||
return;
|
||||
}
|
||||
await requestHubDrain(
|
||||
discovery.url,
|
||||
discovery.authToken,
|
||||
"cline hub upgrade aborted",
|
||||
{ off: true },
|
||||
).catch(() => false);
|
||||
};
|
||||
try {
|
||||
const deadline = Date.now() + cmdOptions.wait * 1_000;
|
||||
let idle = false;
|
||||
// Check at least once so --wait 0 still observes an idle hub.
|
||||
for (;;) {
|
||||
idle = await localHubHasNoActiveSessions(
|
||||
discovery.url,
|
||||
discovery.authToken,
|
||||
).catch(() => true);
|
||||
if (idle || Date.now() >= deadline) {
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
||||
}
|
||||
if (!idle) {
|
||||
await undrain();
|
||||
io.writeErr(
|
||||
"Hub is still serving sessions after the wait window; not replacing it. Re-run with a longer --wait, or finish the sessions first.",
|
||||
);
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
await stopHubServer(opts.cwd);
|
||||
} catch (error) {
|
||||
await undrain();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const { url } = await ensureDetachedHubServer(opts.cwd, {
|
||||
host: opts.host,
|
||||
port: opts.port,
|
||||
pathname: opts.pathname,
|
||||
});
|
||||
io.writeln(JSON.stringify({ upgraded: true, url }));
|
||||
}),
|
||||
);
|
||||
|
||||
return hub;
|
||||
}
|
||||
|
||||
@@ -4,8 +4,7 @@ import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createScheduleCommand } from "./schedule";
|
||||
|
||||
const mockHubClientCommand = vi.hoisted(() => vi.fn());
|
||||
const mockNodeHubClientCtor = vi.hoisted(() => vi.fn());
|
||||
const mockSendHubCommand = vi.hoisted(() => vi.fn());
|
||||
const mockEnsureCliHubServer = vi.hoisted(() => vi.fn());
|
||||
const mockProviderSettings = vi.hoisted(() => ({
|
||||
lastUsed: undefined as { provider?: string; model?: string } | undefined,
|
||||
@@ -17,17 +16,7 @@ vi.mock("@cline/core", async () => {
|
||||
await vi.importActual<typeof import("@cline/core")>("@cline/core");
|
||||
return {
|
||||
...actual,
|
||||
NodeHubClient: class {
|
||||
command = mockHubClientCommand;
|
||||
|
||||
constructor(options: Record<string, unknown>) {
|
||||
mockNodeHubClientCtor(options);
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {}
|
||||
|
||||
close(): void {}
|
||||
},
|
||||
sendHubCommand: mockSendHubCommand,
|
||||
ProviderSettingsManager: class {
|
||||
getLastUsedProviderSettings() {
|
||||
return mockProviderSettings.lastUsed;
|
||||
@@ -85,7 +74,7 @@ describe("runScheduleCommand list output", () => {
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
});
|
||||
mockHubClientCommand.mockResolvedValue({
|
||||
mockSendHubCommand.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedules: [] },
|
||||
});
|
||||
@@ -107,21 +96,18 @@ describe("runScheduleCommand list output", () => {
|
||||
expect(code).toBe(0);
|
||||
expect(errors).toEqual([]);
|
||||
expect(output).toEqual(["No schedules found."]);
|
||||
// Schedule commands are workspace-scoped: the hub client must register
|
||||
// with a workspace context (and the hub auth token) before commanding.
|
||||
expect(mockNodeHubClientCtor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
workspaceRoot: process.cwd(),
|
||||
cwd: process.cwd(),
|
||||
authToken: "test-token",
|
||||
}),
|
||||
expect(mockSendHubCommand).toHaveBeenCalledWith(
|
||||
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
|
||||
{
|
||||
clientId: "cline-schedule",
|
||||
command: "schedule.list",
|
||||
payload: {
|
||||
limit: 100,
|
||||
enabled: undefined,
|
||||
tags: undefined,
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(mockHubClientCommand).toHaveBeenCalledWith("schedule.list", {
|
||||
limit: 100,
|
||||
enabled: undefined,
|
||||
tags: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps JSON list output unchanged when --json is provided", async () => {
|
||||
@@ -129,7 +115,7 @@ describe("runScheduleCommand list output", () => {
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
});
|
||||
mockHubClientCommand.mockResolvedValue({
|
||||
mockSendHubCommand.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedules: [] },
|
||||
});
|
||||
@@ -151,7 +137,7 @@ describe("runScheduleCommand list output", () => {
|
||||
expect(code).toBe(0);
|
||||
expect(errors).toEqual([]);
|
||||
expect(output).toEqual(["[]"]);
|
||||
expect(mockHubClientCommand).toHaveBeenCalled();
|
||||
expect(mockSendHubCommand).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -171,7 +157,7 @@ describe("runScheduleCommand create", () => {
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
});
|
||||
mockHubClientCommand.mockResolvedValue({
|
||||
mockSendHubCommand.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedule: { scheduleId: "sched_123" } },
|
||||
});
|
||||
@@ -203,19 +189,15 @@ describe("runScheduleCommand create", () => {
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(errors).toEqual([]);
|
||||
expect(mockNodeHubClientCtor).toHaveBeenCalledWith(
|
||||
expect(mockSendHubCommand).toHaveBeenCalledWith(
|
||||
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
|
||||
expect.objectContaining({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
workspaceRoot: "/tmp/workspace",
|
||||
cwd: "/tmp/workspace",
|
||||
authToken: "test-token",
|
||||
}),
|
||||
);
|
||||
expect(mockHubClientCommand).toHaveBeenCalledWith(
|
||||
"schedule.create",
|
||||
expect.objectContaining({
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
clientId: "cline-schedule",
|
||||
command: "schedule.create",
|
||||
payload: expect.objectContaining({
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -233,7 +215,7 @@ describe("runScheduleCommand create", () => {
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
});
|
||||
mockHubClientCommand.mockResolvedValue({
|
||||
mockSendHubCommand.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedule: { scheduleId: "sched_123" } },
|
||||
});
|
||||
@@ -264,11 +246,14 @@ describe("runScheduleCommand create", () => {
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(errors).toEqual([]);
|
||||
expect(mockHubClientCommand).toHaveBeenCalledWith(
|
||||
"schedule.create",
|
||||
expect(mockSendHubCommand).toHaveBeenCalledWith(
|
||||
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
|
||||
expect.objectContaining({
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
command: "schedule.create",
|
||||
payload: expect.objectContaining({
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -307,7 +292,7 @@ describe("runScheduleCommand create", () => {
|
||||
expect(errors).toEqual([
|
||||
'No model is configured for provider "anthropic". Pass --model or save a model for that provider before creating the schedule.',
|
||||
]);
|
||||
expect(mockHubClientCommand).not.toHaveBeenCalled();
|
||||
expect(mockSendHubCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps --delivery-bot to delivery.userName", async () => {
|
||||
@@ -315,7 +300,7 @@ describe("runScheduleCommand create", () => {
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
});
|
||||
mockHubClientCommand.mockResolvedValue({
|
||||
mockSendHubCommand.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedule: { scheduleId: "sched_delivery" } },
|
||||
});
|
||||
@@ -354,17 +339,21 @@ describe("runScheduleCommand create", () => {
|
||||
expect(code).toBe(0);
|
||||
expect(errors).toEqual([]);
|
||||
expect(output).toEqual(['{\n "scheduleId": "sched_delivery"\n}']);
|
||||
expect(mockHubClientCommand).toHaveBeenCalledWith(
|
||||
"schedule.create",
|
||||
expect.objectContaining({
|
||||
metadata: {
|
||||
delivery: {
|
||||
adapter: "telegram",
|
||||
threadId: "telegram:123456789",
|
||||
userName: "my_bot",
|
||||
expect(mockSendHubCommand).toHaveBeenCalledWith(
|
||||
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
|
||||
{
|
||||
clientId: "cline-schedule",
|
||||
command: "schedule.create",
|
||||
payload: expect.objectContaining({
|
||||
metadata: {
|
||||
delivery: {
|
||||
adapter: "telegram",
|
||||
threadId: "telegram:123456789",
|
||||
userName: "my_bot",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -381,7 +370,7 @@ describe("runScheduleCommand import", () => {
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
});
|
||||
mockHubClientCommand.mockResolvedValue({
|
||||
mockSendHubCommand.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedule: { scheduleId: "sched_123" } },
|
||||
});
|
||||
@@ -422,12 +411,16 @@ describe("runScheduleCommand import", () => {
|
||||
expect(code).toBe(0);
|
||||
expect(errors).toEqual([]);
|
||||
expect(output).toEqual(['{\n "scheduleId": "sched_123"\n}']);
|
||||
expect(mockHubClientCommand).toHaveBeenCalledWith(
|
||||
"schedule.create",
|
||||
expect.objectContaining({
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
}),
|
||||
expect(mockSendHubCommand).toHaveBeenCalledWith(
|
||||
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
|
||||
{
|
||||
clientId: "cline-schedule",
|
||||
command: "schedule.create",
|
||||
payload: expect.objectContaining({
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
}),
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -451,7 +444,7 @@ describe("runScheduleCommand export", () => {
|
||||
prompt: "review status",
|
||||
workspaceRoot: "/tmp/workspace",
|
||||
};
|
||||
mockHubClientCommand.mockResolvedValue({
|
||||
mockSendHubCommand.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedule: scheduleRecord },
|
||||
});
|
||||
@@ -491,9 +484,14 @@ describe("runScheduleCommand export", () => {
|
||||
|
||||
const written = await readFile(targetPath, "utf8");
|
||||
expect(written).toBe(JSON.stringify(scheduleRecord, null, 2));
|
||||
expect(mockHubClientCommand).toHaveBeenCalledWith("schedule.get", {
|
||||
scheduleId: "sched_abc",
|
||||
});
|
||||
expect(mockSendHubCommand).toHaveBeenCalledWith(
|
||||
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
|
||||
{
|
||||
clientId: "cline-schedule",
|
||||
command: "schedule.get",
|
||||
payload: { scheduleId: "sched_abc" },
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
await rm(targetPath, { force: true });
|
||||
}
|
||||
@@ -509,7 +507,7 @@ describe("runScheduleCommand export", () => {
|
||||
name: "Weekly Sync",
|
||||
cronPattern: "0 9 * * 1",
|
||||
};
|
||||
mockHubClientCommand.mockResolvedValue({
|
||||
mockSendHubCommand.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedule: scheduleRecord },
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
createLocalHubScheduleRuntimeHandlers,
|
||||
HubScheduleCommandService,
|
||||
HubScheduleService,
|
||||
NodeHubClient,
|
||||
sendHubCommand,
|
||||
} from "@cline/core";
|
||||
import {
|
||||
ensureCliHubServer,
|
||||
@@ -11,51 +11,28 @@ import {
|
||||
import type { CommandIo } from "./types";
|
||||
|
||||
export class HubScheduleClient {
|
||||
private hub: Promise<NodeHubClient> | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly url: string,
|
||||
private readonly workspaceRoot: string,
|
||||
private readonly authToken?: string,
|
||||
private readonly endpoint: {
|
||||
host?: string;
|
||||
port?: number;
|
||||
pathname?: string;
|
||||
},
|
||||
) {}
|
||||
|
||||
close(): void {
|
||||
const hub = this.hub;
|
||||
this.hub = undefined;
|
||||
void hub?.then((client) => client.close()).catch(() => undefined);
|
||||
}
|
||||
|
||||
// Schedule commands are authorized against the workspace bound to the
|
||||
// connection's client registration, so all commands must share one
|
||||
// registered connection instead of fire-and-forget envelopes.
|
||||
private connectedHub(): Promise<NodeHubClient> {
|
||||
this.hub ??= (async () => {
|
||||
const client = new NodeHubClient({
|
||||
url: this.url,
|
||||
clientType: "cli-schedule",
|
||||
displayName: "Cline CLI scheduler",
|
||||
workspaceRoot: this.workspaceRoot,
|
||||
cwd: this.workspaceRoot,
|
||||
authToken: this.authToken,
|
||||
});
|
||||
try {
|
||||
await client.connect();
|
||||
} catch (error) {
|
||||
client.close();
|
||||
this.hub = undefined;
|
||||
throw error;
|
||||
}
|
||||
return client;
|
||||
})();
|
||||
return this.hub;
|
||||
}
|
||||
close(): void {}
|
||||
|
||||
private async command(
|
||||
command: string,
|
||||
payload?: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const client = await this.connectedHub();
|
||||
const reply = await client.command(command as never, payload);
|
||||
const reply = await sendHubCommand(this.endpoint, {
|
||||
clientId: "cline-schedule",
|
||||
command: command as never,
|
||||
payload,
|
||||
});
|
||||
if (!reply.ok) {
|
||||
throw new Error(reply.error?.message ?? `hub command failed: ${command}`);
|
||||
}
|
||||
return (reply.payload ?? {}) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -120,7 +97,6 @@ export class LocalScheduleClient {
|
||||
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
|
||||
});
|
||||
private readonly commands = new HubScheduleCommandService(this.service);
|
||||
constructor(private readonly workspaceRoot: string) {}
|
||||
|
||||
close(): void {
|
||||
void this.service.dispose();
|
||||
@@ -130,21 +106,12 @@ export class LocalScheduleClient {
|
||||
command: string,
|
||||
payload?: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const reply = await this.commands.handleCommand(
|
||||
{
|
||||
version: "v1",
|
||||
clientId: "cline-schedule-local",
|
||||
command: command as never,
|
||||
payload,
|
||||
},
|
||||
{
|
||||
clientId: "cline-schedule-local",
|
||||
workspaceContext: {
|
||||
workspaceRoot: this.workspaceRoot,
|
||||
cwd: this.workspaceRoot,
|
||||
},
|
||||
},
|
||||
);
|
||||
const reply = await this.commands.handleCommand({
|
||||
version: "v1",
|
||||
clientId: "cline-schedule-local",
|
||||
command: command as never,
|
||||
payload,
|
||||
});
|
||||
if (!reply.ok) {
|
||||
throw new Error(reply.error?.message ?? `hub command failed: ${command}`);
|
||||
}
|
||||
@@ -218,27 +185,24 @@ export async function ensureSchedulerHub(
|
||||
if (!address?.trim()) {
|
||||
return {
|
||||
ok: true,
|
||||
client: new LocalScheduleClient(
|
||||
workspaceRoot,
|
||||
) as unknown as HubScheduleClient,
|
||||
client: new LocalScheduleClient() as unknown as HubScheduleClient,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const requestedEndpoint = parseHubEndpointOverride(address);
|
||||
const { url: hubUrl, authToken } = await ensureCliHubServer(
|
||||
const { url: hubUrl } = await ensureCliHubServer(
|
||||
workspaceRoot,
|
||||
requestedEndpoint,
|
||||
);
|
||||
const endpoint = parseHubEndpointOverride(hubUrl);
|
||||
return {
|
||||
ok: true,
|
||||
client: new HubScheduleClient(hubUrl, workspaceRoot, authToken),
|
||||
client: new HubScheduleClient(endpoint),
|
||||
};
|
||||
} catch (_error) {
|
||||
return {
|
||||
ok: true,
|
||||
client: new LocalScheduleClient(
|
||||
workspaceRoot,
|
||||
) as unknown as HubScheduleClient,
|
||||
client: new LocalScheduleClient() as unknown as HubScheduleClient,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import {
|
||||
closeSync,
|
||||
@@ -13,11 +13,7 @@ import {
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { HubSessionClient, HubSessionRow } from "@cline/core";
|
||||
import {
|
||||
ensureParentDir,
|
||||
getProcessStartToken,
|
||||
resolveClineDataDir,
|
||||
} from "@cline/core";
|
||||
import { ensureParentDir, resolveClineDataDir } from "@cline/core";
|
||||
import {
|
||||
CLINE_RUN_AS_HUB_DAEMON_ENV,
|
||||
withResolvedClineBuildEnv,
|
||||
@@ -89,6 +85,60 @@ type ProcessProbe = {
|
||||
getStartToken: (pid: number) => string | undefined;
|
||||
};
|
||||
|
||||
function getProcessStartToken(pid: number): string | undefined {
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
if (process.platform === "linux") {
|
||||
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
||||
const commandEnd = stat.lastIndexOf(")");
|
||||
if (commandEnd < 0) {
|
||||
return undefined;
|
||||
}
|
||||
// Fields after the command name begin at field 3 (state), so field
|
||||
// 22 (starttime) is index 19.
|
||||
const startTime = stat
|
||||
.slice(commandEnd + 1)
|
||||
.trim()
|
||||
.split(/\s+/)[19];
|
||||
const bootId = readFileSync(
|
||||
"/proc/sys/kernel/random/boot_id",
|
||||
"utf8",
|
||||
).trim();
|
||||
return startTime && bootId ? `linux:${bootId}:${startTime}` : undefined;
|
||||
}
|
||||
|
||||
const result =
|
||||
process.platform === "win32"
|
||||
? spawnSync(
|
||||
"powershell.exe",
|
||||
[
|
||||
"-NoLogo",
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
`(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks`,
|
||||
],
|
||||
{
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
windowsHide: true,
|
||||
},
|
||||
)
|
||||
: spawnSync("ps", ["-p", String(pid), "-o", "lstart="], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, LC_ALL: "C", TZ: "UTC" },
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
windowsHide: true,
|
||||
});
|
||||
const startTime = result.status === 0 ? result.stdout.trim() : "";
|
||||
return startTime ? `${process.platform}:${startTime}` : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const defaultProcessProbe: ProcessProbe = {
|
||||
isRunning: isProcessRunning,
|
||||
getStartToken: getProcessStartToken,
|
||||
|
||||
@@ -1028,7 +1028,6 @@ export async function handleConnectorUserTurn<
|
||||
const { prompt, userImages, userFiles } = await buildUserInputMessage(
|
||||
runtimeInput,
|
||||
input.userInstructionService,
|
||||
{ mode: startRequest.mode },
|
||||
);
|
||||
try {
|
||||
await input.client.sendRuntimeSession(
|
||||
@@ -1118,7 +1117,6 @@ async function runConnectorRuntimeTurnWithRecovery<
|
||||
const { prompt, userImages, userFiles } = await buildUserInputMessage(
|
||||
runtimeInput,
|
||||
input.userInstructionService,
|
||||
{ mode: startRequest.mode },
|
||||
);
|
||||
const request: ChatRunTurnRequest = {
|
||||
config: startRequest,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useDialogPalette } from "../tui/hooks/use-theme";
|
||||
import { palette } from "../tui/palette";
|
||||
import {
|
||||
type DialogDismissKey,
|
||||
isAnyKeyDismiss,
|
||||
@@ -35,7 +35,6 @@ export function MigrationNoticeContent(
|
||||
},
|
||||
) {
|
||||
const { dialogId, notice, resolve } = props;
|
||||
const palette = useDialogPalette();
|
||||
const subscriptionUrl = useMemo(() => getCliSubscriptionUrl(), []);
|
||||
const [status, setStatus] = useState<string | undefined>();
|
||||
|
||||
@@ -71,6 +70,7 @@ export function MigrationNoticeContent(
|
||||
latest open-weight coding models with enough quota for day-to-day
|
||||
work, at a much lower cost than paying API costs directly.
|
||||
</text>
|
||||
<text selectable>Try it now with a limited-time promo for $4.99.</text>
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
<text fg={palette.act} selectable>
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
} from "./commands/update";
|
||||
import { CLI_DEFAULT_CHECKPOINT_CONFIG } from "./runtime/defaults";
|
||||
import type { TuiStartupTarget } from "./tui/types";
|
||||
import { filterChatModels } from "./utils/chat-models";
|
||||
import { getCliBuildInfo } from "./utils/common";
|
||||
import {
|
||||
buildCliCompactionConfig,
|
||||
@@ -1030,9 +1029,7 @@ export async function runCli(): Promise<void> {
|
||||
`${c.dim}[model-catalog] catalog resolution failed (${message})${c.reset}`,
|
||||
);
|
||||
}
|
||||
const knownModelIds = knownModels
|
||||
? Object.keys(filterChatModels(knownModels))
|
||||
: [];
|
||||
const knownModelIds = knownModels ? Object.keys(knownModels) : [];
|
||||
const resolvedReasoning = resolveCliReasoning({
|
||||
thinking: args.thinking,
|
||||
thinkingExplicitlySet: args.thinkingExplicitlySet,
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { buildUserInputMessage, resolveSystemPrompt } from "./prompt";
|
||||
|
||||
const workspaceDirectories: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of workspaceDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildUserInputMessage } from "./prompt";
|
||||
|
||||
describe("buildUserInputMessage", () => {
|
||||
it("extracts image mentions into userImages", async () => {
|
||||
@@ -52,43 +43,3 @@ describe("buildUserInputMessage", () => {
|
||||
expect(result.userFiles).toEqual([filePath]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSystemPrompt workspace metadata", () => {
|
||||
it("includes git remotes and the latest commit for Cline requests", async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "cline-prompt-"));
|
||||
workspaceDirectories.push(cwd);
|
||||
execFileSync("git", ["init"], { cwd });
|
||||
execFileSync("git", ["config", "user.email", "test@cline.bot"], { cwd });
|
||||
execFileSync("git", ["config", "user.name", "Cline Test"], { cwd });
|
||||
writeFileSync(join(cwd, "README.md"), "test\n");
|
||||
execFileSync("git", ["add", "README.md"], { cwd });
|
||||
execFileSync("git", ["commit", "-m", "initial"], { cwd });
|
||||
execFileSync(
|
||||
"git",
|
||||
["remote", "add", "origin", "https://example.com/cline/repo.git"],
|
||||
{ cwd },
|
||||
);
|
||||
const commit = execFileSync("git", ["rev-parse", "HEAD"], {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
|
||||
const prompt = await resolveSystemPrompt({ cwd, providerId: "cline" });
|
||||
|
||||
expect(prompt).toContain("origin: https://example.com/cline/repo.git");
|
||||
expect(prompt).toContain(commit);
|
||||
});
|
||||
|
||||
it("includes parseable metadata outside a project", async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "cline-prompt-"));
|
||||
workspaceDirectories.push(cwd);
|
||||
|
||||
const prompt = await resolveSystemPrompt({ cwd, providerId: "cline" });
|
||||
|
||||
expect(prompt).toContain("# Workspace Configuration");
|
||||
expect(prompt).toContain(JSON.stringify(cwd));
|
||||
expect(prompt).toContain(`"hint": "${basename(cwd)}"`);
|
||||
expect(prompt).not.toContain("associatedRemoteUrls");
|
||||
expect(prompt).not.toContain("latestGitCommitHash");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,9 +3,7 @@ import { homedir } from "node:os";
|
||||
import { basename, resolve } from "node:path";
|
||||
import {
|
||||
buildWorkspaceMetadata,
|
||||
isSkillsToolAvailable,
|
||||
mergeRulesForSystemPrompt,
|
||||
readGlobalSettings,
|
||||
type UserInstructionConfigService,
|
||||
} from "@cline/core";
|
||||
import { type AgentMode, buildClineSystemPrompt } from "@cline/shared";
|
||||
@@ -81,30 +79,9 @@ function resolveMentionPath(filePath: string): string {
|
||||
return resolve(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a typed `/skill` command must be textually expanded into the
|
||||
* prompt. When the session registers the runtime's `skills` tool (its
|
||||
* description requires the model to invoke it on slash-command references),
|
||||
* the typed command passes through and the instructions arrive as a tool
|
||||
* result — keeping the persisted transcript as what the user typed. When the
|
||||
* tool is unavailable (yolo preset, user toggle), expansion is the only
|
||||
* delivery path.
|
||||
*/
|
||||
export function shouldExpandSkillSlashCommands(mode?: string): boolean {
|
||||
try {
|
||||
return !isSkillsToolAvailable({
|
||||
mode: mode === "plan" || mode === "yolo" ? mode : "act",
|
||||
disabledToolIds: new Set(readGlobalSettings().disabledTools ?? []),
|
||||
});
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildUserInputMessage(
|
||||
rawPrompt: string,
|
||||
userInstructionService?: UserInstructionConfigService,
|
||||
options?: { mode?: string },
|
||||
): Promise<{
|
||||
prompt: string;
|
||||
userImages: string[];
|
||||
@@ -113,9 +90,7 @@ export async function buildUserInputMessage(
|
||||
// First, resolve slash commands if the core config service is available.
|
||||
let prompt = rawPrompt;
|
||||
if (userInstructionService) {
|
||||
prompt = userInstructionService.resolveRuntimeSlashCommand(rawPrompt, {
|
||||
expandSkillCommands: shouldExpandSkillSlashCommands(options?.mode),
|
||||
});
|
||||
prompt = userInstructionService.resolveRuntimeSlashCommand(rawPrompt);
|
||||
}
|
||||
|
||||
if (!hasFileMentions(prompt)) {
|
||||
|
||||
@@ -277,9 +277,7 @@ export async function runAgent(
|
||||
prompt: userInput,
|
||||
userImages,
|
||||
userFiles,
|
||||
} = await buildUserInputMessage(prompt, userInstructionService, {
|
||||
mode: config.mode,
|
||||
});
|
||||
} = await buildUserInputMessage(prompt, userInstructionService);
|
||||
const started = await sessionManager.start({
|
||||
source: SessionSource.CLI,
|
||||
config: {
|
||||
|
||||
@@ -80,6 +80,7 @@ describe("applyInteractiveModelChange", () => {
|
||||
}));
|
||||
const saveProviderSettings = vi.fn(() => ({
|
||||
version: 1 as const,
|
||||
modes: {},
|
||||
providers: {},
|
||||
modes: {},
|
||||
}));
|
||||
|
||||
@@ -621,9 +621,7 @@ export async function runInteractive(
|
||||
prompt: userInput,
|
||||
userImages,
|
||||
userFiles,
|
||||
} = await buildUserInputMessage(input, userInstructionService, {
|
||||
mode,
|
||||
});
|
||||
} = await buildUserInputMessage(input, userInstructionService);
|
||||
const mergedUserImages = [
|
||||
...(attachments?.userImages ?? []),
|
||||
...userImages,
|
||||
|
||||
@@ -78,11 +78,7 @@ export async function runZen(
|
||||
prompt: userInput,
|
||||
userImages,
|
||||
userFiles,
|
||||
} = await buildUserInputMessage(prompt, userInstructionService, {
|
||||
// Zen runs in yolo mode, whose preset has no skills tool — skill
|
||||
// commands must keep expanding textually.
|
||||
mode: "yolo",
|
||||
});
|
||||
} = await buildUserInputMessage(prompt, userInstructionService);
|
||||
|
||||
const startRequest: ChatStartSessionRequest = {
|
||||
workspaceRoot,
|
||||
|
||||
@@ -13,7 +13,6 @@ export function getToolCatalog(
|
||||
): ToolCatalogEntry[] {
|
||||
const modelToolSettings = resolveModelToolSettings();
|
||||
return getCoreBuiltinToolCatalog({
|
||||
clientType: "cli",
|
||||
disabledToolIds: resolveDisabledToolNames(),
|
||||
enabledModelToolIds: new Set(
|
||||
Object.entries(modelToolSettings)
|
||||
|
||||
@@ -169,39 +169,6 @@ describe("slash command registry", () => {
|
||||
expect(expandUserCommandPrompt("/settings", registry)).toBe("/settings");
|
||||
});
|
||||
|
||||
it("keeps skill commands typed when skill expansion is off, but still wraps workflows", () => {
|
||||
const registry = buildSlashCommandRegistry({
|
||||
workflowSlashCommands: [
|
||||
{
|
||||
name: "review",
|
||||
instructions: "Review carefully",
|
||||
description: "Review files",
|
||||
kind: "skill",
|
||||
},
|
||||
{
|
||||
name: "release",
|
||||
instructions: "Run the release workflow",
|
||||
description: "Release",
|
||||
kind: "workflow",
|
||||
},
|
||||
],
|
||||
});
|
||||
const options = { expandSkillCommands: false };
|
||||
|
||||
// The skills tool delivers the instructions; the transcript keeps the
|
||||
// typed command.
|
||||
expect(
|
||||
expandUserCommandPrompt("/review this file", registry, options),
|
||||
).toBe("/review this file");
|
||||
expect(
|
||||
expandUserCommandPrompt("please /review this file", registry, options),
|
||||
).toBe("please /review this file");
|
||||
// Workflows are not served by the skills tool and keep expanding.
|
||||
expect(expandUserCommandPrompt("/release now", registry, options)).toBe(
|
||||
'<user_command slash="release">Run the release workflow</user_command> now',
|
||||
);
|
||||
});
|
||||
|
||||
it("does not expand commands omitted from a refreshed user-command registry", () => {
|
||||
const staleRegistry = buildSlashCommandRegistry({
|
||||
workflowSlashCommands: [
|
||||
|
||||
@@ -245,33 +245,19 @@ export function formatSlashCommandAutocompleteValue(
|
||||
return `/${entry.name} `;
|
||||
}
|
||||
|
||||
export interface ExpandUserCommandPromptOptions {
|
||||
/**
|
||||
* Whether matched skill commands are wrapped into the prompt. Pass false
|
||||
* when the session registers the runtime's skills tool: the typed
|
||||
* `/skill args` then goes through as-is and the model loads the
|
||||
* instructions via the tool. Workflows always expand (the tool does not
|
||||
* serve them). Defaults to true.
|
||||
*/
|
||||
expandSkillCommands?: boolean;
|
||||
}
|
||||
|
||||
export function expandUserCommandPrompt(
|
||||
input: string,
|
||||
registry: SlashCommandRegistry,
|
||||
options?: ExpandUserCommandPromptOptions,
|
||||
): string {
|
||||
if (input.includes("<user_command")) {
|
||||
return input;
|
||||
}
|
||||
const skipCommand = (command: SlashCommandRegistryEntry): boolean =>
|
||||
command.kind === "skill" && options?.expandSkillCommands === false;
|
||||
|
||||
const expandedSlashCommands = input.replace(
|
||||
USER_COMMAND_SLASH_PATTERN,
|
||||
(match, prefix: string, name: string) => {
|
||||
const command = resolveSlashCommand(registry, name);
|
||||
if (command?.execution !== "user-command" || skipCommand(command)) {
|
||||
if (command?.execution !== "user-command") {
|
||||
return match;
|
||||
}
|
||||
return `${prefix}${formatUserCommandBlock(command.instructions, command.name)}`;
|
||||
@@ -286,11 +272,7 @@ export function expandUserCommandPrompt(
|
||||
return input;
|
||||
}
|
||||
const command = resolveSlashCommand(registry, match[1] ?? "");
|
||||
if (
|
||||
!command ||
|
||||
command.execution !== "user-command" ||
|
||||
skipCommand(command)
|
||||
) {
|
||||
if (!command || command.execution !== "user-command") {
|
||||
return input;
|
||||
}
|
||||
const rest = (match[2] ?? "").trim();
|
||||
|
||||
@@ -657,18 +657,11 @@ export function ChatEntryView(props: {
|
||||
* token identity, so settled content never re-renders.
|
||||
* tableOptions preserves the bordered table style that coalesced
|
||||
* mode used by default (top-level defaults to borderless columns).
|
||||
*
|
||||
* streaming stays true even after the entry settles: flipping the
|
||||
* prop makes MarkdownRenderable rebuild every block from scratch
|
||||
* (updateBlocks(true) skips all reuse paths), so the finished
|
||||
* message flashes back to unhighlighted text while tree-sitter
|
||||
* re-highlights. opencode's TUI keeps streaming={true} for the
|
||||
* same reason. entry.streaming still drives the spinner glyph.
|
||||
*/}
|
||||
<markdown
|
||||
content={content}
|
||||
syntaxStyle={getSyntaxStyle(theme, mode)}
|
||||
streaming={true}
|
||||
streaming={entry.streaming}
|
||||
internalBlockMode="top-level"
|
||||
tableOptions={{ style: "grid" }}
|
||||
fg={defaultFg}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { useRenderer } from "@opentui/react";
|
||||
import { DialogContainerRenderable } from "@opentui-ui/dialog";
|
||||
import { useDialogState } from "@opentui-ui/dialog/react";
|
||||
import { useEffect } from "react";
|
||||
import { useTheme } from "../hooks/use-theme";
|
||||
import { getDialogSurface } from "../themes";
|
||||
|
||||
/**
|
||||
* Keeps dialog panel backgrounds in sync with the active theme.
|
||||
*
|
||||
* The dialog library computes a panel's style once when the dialog opens
|
||||
* (from the container's dialogOptions), so theme changes made while a dialog
|
||||
* is open — most visibly the live preview while scrolling the theme picker —
|
||||
* would leave the panel on the old surface color. This component pushes the
|
||||
* theme's dialog surface into the container (for dialogs opened later) and
|
||||
* onto every open dialog renderable (repainting them in place).
|
||||
*
|
||||
* Must be mounted inside the DialogProvider. Re-runs when a dialog opens so
|
||||
* the first dialog after mount is covered too (the container is only added
|
||||
* to the renderer root after this component's initial effect).
|
||||
*/
|
||||
export function DialogThemeSync() {
|
||||
const renderer = useRenderer();
|
||||
const theme = useTheme();
|
||||
const dialogCount = useDialogState((state: { count: number }) => state.count);
|
||||
const surface = getDialogSurface(theme);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: dialogCount re-runs the sync when a dialog opens, covering dialogs opened before the container-level option applied (see docblock).
|
||||
useEffect(() => {
|
||||
const container = renderer.root
|
||||
.getChildren()
|
||||
.find(
|
||||
(child): child is DialogContainerRenderable =>
|
||||
child instanceof DialogContainerRenderable,
|
||||
);
|
||||
if (!container) return;
|
||||
container.dialogOptions = { style: { backgroundColor: surface } };
|
||||
for (const [, dialogRenderable] of container.getDialogRenderables()) {
|
||||
dialogRenderable.backgroundColor = surface;
|
||||
}
|
||||
}, [renderer, surface, dialogCount]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
formatClineCredits,
|
||||
isClineAccountAuthErrorMessage,
|
||||
} from "../../cline-account";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
import { palette } from "../../palette";
|
||||
|
||||
export type AccountDialogAction =
|
||||
| "change-model"
|
||||
@@ -121,7 +121,6 @@ function AccountActionRow(props: {
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const palette = useDialogPalette();
|
||||
const fg = props.selected ? palette.textOnSelection : undefined;
|
||||
return (
|
||||
<box
|
||||
@@ -139,7 +138,7 @@ function AccountActionRow(props: {
|
||||
fg={props.selected ? palette.textOnSelection : "gray"}
|
||||
flexShrink={0}
|
||||
>
|
||||
{props.selected ? "❯" : " "}
|
||||
{props.selected ? ">" : " "}
|
||||
</text>
|
||||
<text fg={fg} flexShrink={0}>
|
||||
{props.action.label}
|
||||
@@ -162,7 +161,6 @@ function OrganizationRow(props: {
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const palette = useDialogPalette();
|
||||
return (
|
||||
<box
|
||||
flexDirection="row"
|
||||
@@ -179,7 +177,7 @@ function OrganizationRow(props: {
|
||||
fg={props.selected ? palette.textOnSelection : "gray"}
|
||||
flexShrink={0}
|
||||
>
|
||||
{props.selected ? "❯" : " "}
|
||||
{props.selected ? ">" : " "}
|
||||
</text>
|
||||
<text
|
||||
fg={props.selected ? palette.textOnSelection : undefined}
|
||||
@@ -236,7 +234,6 @@ export function AccountDialogContent(
|
||||
switchAccount,
|
||||
onAccountChange,
|
||||
} = props;
|
||||
const palette = useDialogPalette();
|
||||
const [state, setState] = useState<AccountState>({
|
||||
status: "loading",
|
||||
message: "Loading account details...",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
import { palette } from "../../palette";
|
||||
|
||||
export function AskQuestionContent(
|
||||
props: ChoiceContext<string | null> & {
|
||||
@@ -11,7 +11,6 @@ export function AskQuestionContent(
|
||||
},
|
||||
) {
|
||||
const { resolve, dialogId, question, options } = props;
|
||||
const palette = useDialogPalette();
|
||||
const [selected, setSelected] = useState(0);
|
||||
const [inputKey, setInputKey] = useState(0);
|
||||
const customRef = useRef("");
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
import { palette } from "../../palette";
|
||||
|
||||
export type CheckpointRestoreMode = "chat-only" | "chat-and-workspace";
|
||||
|
||||
@@ -29,7 +29,6 @@ export function CheckpointConfirmContent(
|
||||
},
|
||||
) {
|
||||
const { resolve, dismiss, dialogId, messagePreview } = props;
|
||||
const palette = useDialogPalette();
|
||||
const [selected, setSelected] = useState(0);
|
||||
const selectedRef = useRef(0);
|
||||
const selectedMode = OPTIONS[selected]?.value;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
import { palette } from "../../palette";
|
||||
|
||||
export interface CheckpointPickerItem {
|
||||
runCount: number;
|
||||
@@ -36,7 +36,6 @@ export function CheckpointPickerContent(
|
||||
},
|
||||
) {
|
||||
const { resolve, dismiss, dialogId, items } = props;
|
||||
const palette = useDialogPalette();
|
||||
const lastIndex = Math.max(0, items.length - 1);
|
||||
const [selected, setSelected] = useState(lastIndex);
|
||||
const selectedRef = useRef(lastIndex);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTerminalDimensions } from "@opentui/react";
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
import { palette } from "../../palette";
|
||||
import {
|
||||
buildCommandPaletteItems,
|
||||
type CommandPaletteResult,
|
||||
@@ -32,7 +32,6 @@ export function CommandPaletteContent(
|
||||
},
|
||||
) {
|
||||
const { resolve, dismiss, dialogId, canForkSession, contentWidth } = props;
|
||||
const palette = useDialogPalette();
|
||||
const { height } = useTerminalDimensions();
|
||||
const [query, setQuery] = useState("");
|
||||
const [selected, setSelected] = useState(0);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import { useState } from "react";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
import type {
|
||||
InteractiveConfigData,
|
||||
InteractiveConfigItem,
|
||||
LoadInteractiveConfigDataOptions,
|
||||
} from "../../interactive-config";
|
||||
import { palette } from "../../palette";
|
||||
import {
|
||||
getExtDetailFooterText,
|
||||
getExtDetailRows,
|
||||
@@ -23,7 +23,6 @@ export function ExtDetailContent(
|
||||
) => Promise<InteractiveConfigData | undefined>;
|
||||
},
|
||||
) {
|
||||
const palette = useDialogPalette();
|
||||
const [item, setItem] = useState(props.item);
|
||||
const [toggleError, setToggleError] = useState<string | undefined>();
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @jsxImportSource @opentui/react
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
import { palette } from "../../palette";
|
||||
|
||||
type HelpRow =
|
||||
| { kind: "heading"; id: string; text: string }
|
||||
@@ -255,7 +255,6 @@ const KEY_WIDTH = 20;
|
||||
|
||||
export function HelpDialogContent(props: ChoiceContext<void>) {
|
||||
const { dismiss, dialogId } = props;
|
||||
const palette = useDialogPalette();
|
||||
|
||||
useDialogKeyboard((key) => {
|
||||
if (
|
||||
|
||||
@@ -14,27 +14,6 @@ export function resolveHubUpdateRequiredKeyAction(
|
||||
return "ignore";
|
||||
}
|
||||
|
||||
/**
|
||||
* Human phrase for the live work an outdated Hub is serving, used by the
|
||||
* "Hub update required" dialog. Falls back to an unquantified phrase when the
|
||||
* Hub could not answer the activity query.
|
||||
*/
|
||||
export function describeOutdatedHubSessions(counts: {
|
||||
activeSessionCount?: number;
|
||||
participantClientCount?: number;
|
||||
}): string {
|
||||
const sessions = counts.activeSessionCount;
|
||||
if (typeof sessions !== "number" || sessions <= 0) {
|
||||
return "active sessions from other Cline clients";
|
||||
}
|
||||
const sessionsPhrase = `${sessions} active session${sessions === 1 ? "" : "s"}`;
|
||||
const clients = counts.participantClientCount;
|
||||
if (typeof clients !== "number" || clients <= 0) {
|
||||
return sessionsPhrase;
|
||||
}
|
||||
return `${sessionsPhrase} from ${clients} connected Cline client${clients === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Yolo and sandbox sessions force the local backend and never attach to the
|
||||
* shared managed Hub (see the forceLocalBackend condition in the interactive
|
||||
|
||||
@@ -1,27 +1,17 @@
|
||||
// @jsxImportSource @opentui/react
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
import {
|
||||
describeOutdatedHubSessions,
|
||||
resolveHubUpdateRequiredKeyAction,
|
||||
} from "./hub-update-required-helpers";
|
||||
import { palette } from "../../palette";
|
||||
import { resolveHubUpdateRequiredKeyAction } from "./hub-update-required-helpers";
|
||||
|
||||
export interface HubUpdateRequiredDetails {
|
||||
hubCoreVersion?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown only for `unsupported_protocol`: the running Hub speaks a protocol
|
||||
* this CLI cannot, so nothing hub-backed works until the CLI updates. The
|
||||
* softer `build_mismatch` case (newer Hub, compatible protocol) is a toast
|
||||
* in root.tsx, not this modal.
|
||||
*/
|
||||
export function HubUpdateRequiredContent(
|
||||
props: ChoiceContext<boolean> & HubUpdateRequiredDetails,
|
||||
) {
|
||||
const { dialogId, dismiss, hubCoreVersion, resolve } = props;
|
||||
const palette = useDialogPalette();
|
||||
|
||||
useDialogKeyboard((key) => {
|
||||
const action = resolveHubUpdateRequiredKeyAction(key);
|
||||
@@ -38,12 +28,13 @@ export function HubUpdateRequiredContent(
|
||||
<text fg="yellow">Cline Hub was updated</text>
|
||||
<box flexDirection="column">
|
||||
<text selectable>
|
||||
Another Cline installation updated the shared Cline Hub
|
||||
{hubCoreVersion ? ` (core ${hubCoreVersion})` : ""} to a version this
|
||||
CLI cannot talk to.
|
||||
Another Cline installation restarted the shared Cline Hub
|
||||
{hubCoreVersion ? ` (core ${hubCoreVersion})` : ""}, and it no longer
|
||||
matches this CLI.
|
||||
</text>
|
||||
<text selectable>
|
||||
Update and restart Cline to reconnect to the running Hub.
|
||||
Update and restart Cline so this CLI and the Hub run the same version
|
||||
again.
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
@@ -57,64 +48,3 @@ export function HubUpdateRequiredContent(
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
export interface HubOutdatedDetails {
|
||||
hubCoreVersion?: string;
|
||||
activeSessionCount?: number;
|
||||
participantClientCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown when this CLI is the newer build and the shared Hub was left running
|
||||
* an older one because it is still serving other clients' sessions. Enter
|
||||
* replaces the Hub now (interrupting that work); Esc keeps it running.
|
||||
*/
|
||||
export function HubOutdatedContent(
|
||||
props: ChoiceContext<boolean> & HubOutdatedDetails,
|
||||
) {
|
||||
const {
|
||||
activeSessionCount,
|
||||
dialogId,
|
||||
dismiss,
|
||||
participantClientCount,
|
||||
resolve,
|
||||
} = props;
|
||||
const palette = useDialogPalette();
|
||||
|
||||
useDialogKeyboard((key) => {
|
||||
const action = resolveHubUpdateRequiredKeyAction(key);
|
||||
if (action === "ignore") return;
|
||||
if (action === "update") {
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
dismiss();
|
||||
}, dialogId);
|
||||
|
||||
return (
|
||||
<box flexDirection="column" paddingX={1} gap={1}>
|
||||
<text fg="yellow">Cline Hub update required</text>
|
||||
<box flexDirection="column">
|
||||
<text selectable>
|
||||
This CLI needs a newer Cline Hub, but the running one is still serving{" "}
|
||||
{describeOutdatedHubSessions({
|
||||
activeSessionCount,
|
||||
participantClientCount,
|
||||
})}
|
||||
.
|
||||
</text>
|
||||
<text selectable>
|
||||
Updating stops that Hub and interrupts its sessions.
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
<box paddingX={1} backgroundColor={palette.act}>
|
||||
<text fg={palette.textOnSelection}>Update Now</text>
|
||||
</box>
|
||||
</box>
|
||||
<text fg={palette.muted}>
|
||||
Press Enter to update now, Esc to keep the Hub running
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import { useState } from "react";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
import { palette } from "../../palette";
|
||||
|
||||
export interface McpEntry {
|
||||
name: string;
|
||||
@@ -70,7 +70,6 @@ export function McpManagerContent(
|
||||
servers: McpEntry[];
|
||||
},
|
||||
) {
|
||||
const palette = useDialogPalette();
|
||||
const [selected, setSelected] = useState(0);
|
||||
const [servers, setServers] = useState(props.servers);
|
||||
const [changed, setChanged] = useState(false);
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
type ProviderSettingsManager,
|
||||
saveLocalProviderSettings,
|
||||
} from "@cline/core";
|
||||
import { CLI_PROMO_CODE } from "../../../utils/cline-pass-errors";
|
||||
import {
|
||||
type DialogDismissKey,
|
||||
isAnyKeyDismiss,
|
||||
@@ -76,5 +77,8 @@ export function buildClinePassSubscriptionPageUrl(
|
||||
appBaseUrl || DEFAULT_APP_BASE_URL,
|
||||
);
|
||||
url.searchParams.set("personal", "true");
|
||||
if (CLI_PROMO_CODE) {
|
||||
url.searchParams.set("code", CLI_PROMO_CODE);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
} from "../../../utils/codex-cli";
|
||||
import open from "../../../utils/open";
|
||||
import { listLocalProviders } from "../../../utils/provider-catalog";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
import { palette } from "../../palette";
|
||||
import {
|
||||
getDefaultAwsRegion,
|
||||
type ProviderConfigValues,
|
||||
@@ -63,7 +63,6 @@ export function ProviderPickerContent(
|
||||
props: ChoiceContext<string> & { currentProviderId: string },
|
||||
) {
|
||||
const { resolve, dismiss, dialogId, currentProviderId } = props;
|
||||
const palette = useDialogPalette();
|
||||
const [providers, setProviders] = useState<ProviderItem[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
const [selected, setSelected] = useState(0);
|
||||
@@ -273,7 +272,6 @@ export function UseExistingOrReconfigureContent(
|
||||
},
|
||||
) {
|
||||
const { resolve, dismiss, dialogId, providerName, extraOptions } = props;
|
||||
const palette = useDialogPalette();
|
||||
const options: ExistingProviderOption[] = useMemo(
|
||||
() => [
|
||||
{ value: "use_existing", label: "Use existing configuration" },
|
||||
@@ -353,7 +351,6 @@ function ClinePassBrowserPageContent(
|
||||
url,
|
||||
openedStatus,
|
||||
} = props;
|
||||
const palette = useDialogPalette();
|
||||
const [status, setStatus] = useState("Opening browser...");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -492,7 +489,6 @@ export function ProviderConfigInputContent(
|
||||
providerName,
|
||||
providerSettingsManager,
|
||||
} = props;
|
||||
const palette = useDialogPalette();
|
||||
|
||||
const config = useMemo(
|
||||
() => getProviderConfigFields(providerId),
|
||||
@@ -660,7 +656,6 @@ export function CodexCliStatusContent(
|
||||
},
|
||||
) {
|
||||
const { resolve, dismiss, dialogId, providerName } = props;
|
||||
const palette = useDialogPalette();
|
||||
const [status, setStatus] = useState<CodexCliStatus | undefined>();
|
||||
const [checking, setChecking] = useState(false);
|
||||
|
||||
@@ -754,7 +749,6 @@ export function OAuthLoginContent(
|
||||
providerName,
|
||||
allowApiKeyFallback,
|
||||
} = props;
|
||||
const palette = useDialogPalette();
|
||||
const [mode, setMode] = useState<"browser" | "device">(
|
||||
providerId === "cline" ? "device" : "browser",
|
||||
);
|
||||
@@ -975,7 +969,6 @@ export function OAuthApiKeyInputContent(
|
||||
providerName,
|
||||
providerSettingsManager,
|
||||
} = props;
|
||||
const palette = useDialogPalette();
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
const submit = () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import type { SlashCommandRegistryEntry } from "../../commands/slash-command-registry";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
import { palette } from "../../palette";
|
||||
|
||||
export const SKILLS_MARKETPLACE_ACTION = "__skills_marketplace__";
|
||||
export const SKILLS_MARKETPLACE_URL = "https://skills.sh/";
|
||||
@@ -26,7 +26,6 @@ function matchesFilter(
|
||||
|
||||
export function SkillsPickerContent(props: SkillsPickerContentProps) {
|
||||
const { resolve, dismiss, dialogId, commands } = props;
|
||||
const palette = useDialogPalette();
|
||||
const { height, width } = useTerminalDimensions();
|
||||
const [filter, setFilter] = useState("");
|
||||
const [selected, setSelected] = useState(0);
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useTerminalDimensions } from "@opentui/react";
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useDialogPalette, useThemeController } from "../../hooks/use-theme";
|
||||
import { useThemeController } from "../../hooks/use-theme";
|
||||
import { palette } from "../../palette";
|
||||
import { getThemeSwatchColors, THEMES } from "../../themes";
|
||||
|
||||
const SWATCH_BLOCK = "\u25a0";
|
||||
@@ -11,7 +12,6 @@ export function ThemePickerContent(props: ChoiceContext<string>) {
|
||||
const { resolve, dismiss, dialogId } = props;
|
||||
const { height } = useTerminalDimensions();
|
||||
const controller = useThemeController();
|
||||
const palette = useDialogPalette();
|
||||
const [selected, setSelected] = useState(() => {
|
||||
const index = THEMES.findIndex(
|
||||
(theme) => theme.id === controller.selectedThemeId,
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ToolApprovalRequest } from "@cline/shared";
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import type React from "react";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
import { palette } from "../../palette";
|
||||
import {
|
||||
buildReadFilesKeys,
|
||||
parseApplyPatchInput,
|
||||
@@ -139,7 +139,6 @@ export function formatApprovalParams(
|
||||
export function ToolApprovalContent(
|
||||
props: ChoiceContext<boolean> & { request: ToolApprovalRequest },
|
||||
) {
|
||||
const palette = useDialogPalette();
|
||||
useDialogKeyboard((key) => {
|
||||
if (key.name === "y" || key.name === "return") {
|
||||
props.resolve(true);
|
||||
|
||||
@@ -7,8 +7,7 @@ import {
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import "opentui-spinner/react";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
import type { DialogPalette } from "../../themes";
|
||||
import { palette } from "../../palette";
|
||||
import {
|
||||
CLINE_MODEL_PICKER_TIER_LABELS,
|
||||
type ClineModelPickerEntry,
|
||||
@@ -25,7 +24,7 @@ export {
|
||||
freeTierDescriptionFor,
|
||||
} from "./cline-model-entries";
|
||||
|
||||
function tagColor(tag: string, palette: DialogPalette): string {
|
||||
function tagColor(tag: string): string {
|
||||
if (tag === "FREE") return palette.success;
|
||||
if (tag === "BEST") return "magenta";
|
||||
return palette.act;
|
||||
@@ -59,7 +58,6 @@ export function ClineModelPicker(props: {
|
||||
currentModelId?: string;
|
||||
}) {
|
||||
const { entries, selected, loading, currentModelId } = props;
|
||||
const palette = useDialogPalette();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -121,7 +119,7 @@ export function ClineModelPicker(props: {
|
||||
{tags.map((t) => (
|
||||
<text
|
||||
key={t}
|
||||
fg={isSel ? palette.textOnSelection : tagColor(t, palette)}
|
||||
fg={isSel ? palette.textOnSelection : tagColor(t)}
|
||||
flexShrink={0}
|
||||
>
|
||||
{t}
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
import type { DialogPalette } from "../../themes";
|
||||
import { palette } from "../../palette";
|
||||
import {
|
||||
CLINE_MODEL_PICKER_TIER_LABELS,
|
||||
type ClineModelPickerEntry,
|
||||
@@ -19,7 +18,7 @@ type ClineModelEntriesState =
|
||||
| { status: "loaded"; entries: ClineModelPickerEntry[] }
|
||||
| { status: "error"; message: string };
|
||||
|
||||
function tagColor(tag: string, palette: DialogPalette): string {
|
||||
function tagColor(tag: string): string {
|
||||
if (tag === "FREE") return palette.success;
|
||||
if (tag === "BEST") return "magenta";
|
||||
return palette.act;
|
||||
@@ -40,7 +39,6 @@ export function ClineModelSelectorContent(
|
||||
currentProviderName,
|
||||
entries,
|
||||
} = props;
|
||||
const palette = useDialogPalette();
|
||||
const [selected, setSelected] = useState(0);
|
||||
const [onProvider, setOnProvider] = useState(false);
|
||||
|
||||
@@ -190,7 +188,7 @@ export function ClineModelSelectorContent(
|
||||
{row.tags.map((t) => (
|
||||
<text
|
||||
key={t}
|
||||
fg={isSel ? palette.textOnSelection : tagColor(t, palette)}
|
||||
fg={isSel ? palette.textOnSelection : tagColor(t)}
|
||||
flexShrink={0}
|
||||
>
|
||||
{t}
|
||||
@@ -224,7 +222,6 @@ export function ClineModelSelectorDialogContent(
|
||||
},
|
||||
) {
|
||||
const { dismiss, dialogId, loadEntries } = props;
|
||||
const palette = useDialogPalette();
|
||||
const [state, setState] = useState<ClineModelEntriesState>({
|
||||
status: "loading",
|
||||
message: "Loading Cline models...",
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Llms } from "@cline/core";
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
import { palette } from "../../palette";
|
||||
import { ProviderRow } from "./provider-row";
|
||||
|
||||
export interface ModelOption {
|
||||
@@ -65,7 +65,6 @@ export function ModelIdInputContent(
|
||||
) {
|
||||
const { resolve, dismiss, dialogId, currentModel, currentProviderName } =
|
||||
props;
|
||||
const palette = useDialogPalette();
|
||||
const [modelId, setModelId] = useState(currentModel);
|
||||
const [error, setError] = useState("");
|
||||
const [onProvider, setOnProvider] = useState(false);
|
||||
@@ -329,7 +328,6 @@ export function ThinkingLevelContent(
|
||||
},
|
||||
) {
|
||||
const { resolve, dismiss, dialogId, modelName, currentLevel } = props;
|
||||
const palette = useDialogPalette();
|
||||
const [selected, setSelected] = useState(() => {
|
||||
const initialLevel = currentLevel === "none" ? "medium" : currentLevel;
|
||||
const idx = THINKING_LEVELS.findIndex((l) => l.value === initialLevel);
|
||||
@@ -518,7 +516,6 @@ function CreateCustomModelRow(props: {
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const { isSelected, dimmed, onSelect } = props;
|
||||
const palette = useDialogPalette();
|
||||
const active = isSelected && !dimmed;
|
||||
const bg = active
|
||||
? palette.selection
|
||||
@@ -556,7 +553,6 @@ function ModelRow(props: {
|
||||
onSelect: (key: string) => void;
|
||||
}) {
|
||||
const { model, isSelected, dimmed, isCurrent, onSelect } = props;
|
||||
const palette = useDialogPalette();
|
||||
const active = isSelected && !dimmed;
|
||||
const bg = active
|
||||
? palette.selection
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @jsxImportSource @opentui/react
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
import { palette } from "../../palette";
|
||||
|
||||
export function ProviderRow({
|
||||
providerName,
|
||||
@@ -8,7 +8,6 @@ export function ProviderRow({
|
||||
providerName: string;
|
||||
focused: boolean;
|
||||
}) {
|
||||
const palette = useDialogPalette();
|
||||
return (
|
||||
<box flexDirection="row" paddingX={1} gap={1}>
|
||||
<text fg={focused ? palette.selection : "gray"} flexShrink={0}>
|
||||
|
||||
@@ -55,6 +55,7 @@ function BashOutput(props: { fullText: string; theme: ResolvedTheme }) {
|
||||
|
||||
if (!expanded) {
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI box is a terminal renderable, not a DOM element; mouse expands optional output.
|
||||
<box
|
||||
flexDirection="column"
|
||||
paddingLeft={2}
|
||||
@@ -75,6 +76,7 @@ function BashOutput(props: { fullText: string; theme: ResolvedTheme }) {
|
||||
}
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI box is a terminal renderable, not a DOM element; mouse collapses output.
|
||||
<box
|
||||
flexDirection="column"
|
||||
paddingLeft={2}
|
||||
@@ -160,6 +162,7 @@ function EditOutput(props: {
|
||||
const diffPalette = props.theme.diff;
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI box is a terminal renderable, not a DOM element; mouse toggles the diff.
|
||||
<box
|
||||
flexDirection="column"
|
||||
paddingLeft={2}
|
||||
@@ -216,6 +219,7 @@ function ApplyPatchOutput(props: {
|
||||
const diffPalette = props.theme.diff;
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI box is a terminal renderable, not a DOM element; mouse toggles the diff.
|
||||
<box
|
||||
flexDirection="column"
|
||||
paddingLeft={2}
|
||||
@@ -263,6 +267,7 @@ function GenericOutput(props: { outputSummary: string; fullText?: string }) {
|
||||
: displayText;
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI box is a terminal renderable, not a DOM element; mouse expands long output.
|
||||
<box
|
||||
flexDirection="column"
|
||||
paddingLeft={2}
|
||||
@@ -278,6 +283,7 @@ function GenericOutput(props: { outputSummary: string; fullText?: string }) {
|
||||
}
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI box is a terminal renderable, not a DOM element; mouse collapses output.
|
||||
<box
|
||||
flexDirection="column"
|
||||
paddingLeft={2}
|
||||
@@ -304,6 +310,7 @@ export function ToolOutput(props: ToolOutputProps) {
|
||||
const showDetail =
|
||||
errorExpanded && presentation.detail !== presentation.summary.trim();
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI box is a terminal renderable, not a DOM element; mouse toggles error details.
|
||||
<box
|
||||
flexDirection="column"
|
||||
paddingLeft={2}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useLayoutEffect, useRef, useState } from "react";
|
||||
import { shouldExpandSkillSlashCommands } from "../../runtime/prompt";
|
||||
import { formatCliErrorMessage } from "../../utils/cline-pass-errors";
|
||||
import { shouldShowCliUsageCost } from "../../utils/usage-cost-display";
|
||||
import type { SlashCommandRegistry } from "../commands/slash-command-registry";
|
||||
@@ -309,12 +308,6 @@ export function usePromptInputController(input: {
|
||||
const promptForSubmit = expandUserCommandPrompt(
|
||||
expandedPrompt,
|
||||
slashCommandRegistry,
|
||||
// Skills load through the runtime's skills tool when it is
|
||||
// available for the current mode; the typed command then goes
|
||||
// through as-is so the transcript keeps what the user typed.
|
||||
{
|
||||
expandSkillCommands: shouldExpandSkillSlashCommands(session.uiMode),
|
||||
},
|
||||
);
|
||||
|
||||
session.setHasSubmitted(true);
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import type { AgentMode } from "@cline/core";
|
||||
import {
|
||||
type ToolApprovalRequest,
|
||||
type ToolApprovalResult,
|
||||
USER_REJECTED_TOOL_REASON,
|
||||
} from "@cline/shared";
|
||||
import type { ToolApprovalRequest, ToolApprovalResult } from "@cline/shared";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { RuntimeToolInteraction, TuiProps } from "../types";
|
||||
|
||||
@@ -40,16 +36,16 @@ function toRuntimeToolInteraction(
|
||||
};
|
||||
}
|
||||
|
||||
function deniedToolResult(): ToolApprovalResult {
|
||||
function deniedToolResult(request: ToolApprovalRequest): ToolApprovalResult {
|
||||
return {
|
||||
approved: false,
|
||||
reason: USER_REJECTED_TOOL_REASON,
|
||||
reason: `Tool "${request.toolName}" was denied by user`,
|
||||
};
|
||||
}
|
||||
|
||||
function dismissPendingInteraction(pending: PendingRuntimeToolInteraction) {
|
||||
if (pending.kind === "tool_approval") {
|
||||
pending.resolve(deniedToolResult());
|
||||
pending.resolve(deniedToolResult(pending.request));
|
||||
return;
|
||||
}
|
||||
pending.resolve("[User dismissed the question]");
|
||||
@@ -115,7 +111,9 @@ export function useRuntimeDialogBridge(input: {
|
||||
if (!pending || pending.id !== id || pending.kind !== "tool_approval") {
|
||||
return;
|
||||
}
|
||||
pending.resolve(approved ? { approved: true } : deniedToolResult());
|
||||
pending.resolve(
|
||||
approved ? { approved: true } : deniedToolResult(pending.request),
|
||||
);
|
||||
const hasNext = finishActive(id);
|
||||
if (!hasNext) {
|
||||
refocusTextarea();
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
import { createContext, useContext } from "react";
|
||||
import type { TerminalTheme } from "../palette";
|
||||
import {
|
||||
AUTO_THEME_ID,
|
||||
type DialogPalette,
|
||||
getDialogPalette,
|
||||
type ResolvedTheme,
|
||||
resolveTheme,
|
||||
} from "../themes";
|
||||
import { AUTO_THEME_ID, type ResolvedTheme, resolveTheme } from "../themes";
|
||||
|
||||
export interface TerminalColors {
|
||||
background: string | null;
|
||||
@@ -47,17 +41,6 @@ export function useTheme(): ResolvedTheme {
|
||||
return controller?.theme ?? resolveTheme(AUTO_THEME_ID, detected);
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme-following colors for dialog content. Unlike the static `palette`
|
||||
* constant, this re-resolves whenever the active theme changes, so open
|
||||
* dialogs repaint live during theme previews (e.g. scrolling the theme
|
||||
* picker).
|
||||
*/
|
||||
export function useDialogPalette(): DialogPalette {
|
||||
const theme = useTheme();
|
||||
return useMemo(() => getDialogPalette(theme), [theme]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Background that adaptive colors (input field, user bubbles, rules) derive
|
||||
* from: the theme's painted background when set, else the detected one.
|
||||
|
||||
@@ -169,15 +169,6 @@ export function getUserMessageBackground(terminalBg: string | null): string {
|
||||
return liftedFromTerminalBg(terminalBg, BASE_LIFT, 0, 0);
|
||||
}
|
||||
|
||||
// Dialog panels lift the theme background the same way, but a touch softer:
|
||||
// they cover a large area and sit over a dimmed backdrop, so a smaller step
|
||||
// already reads as "raised" without washing out the theme's hue.
|
||||
const DIALOG_SURFACE_LIFT = 0.16;
|
||||
|
||||
export function getDialogSurfaceBackground(terminalBg: string | null): string {
|
||||
return liftedFromTerminalBg(terminalBg, DIALOG_SURFACE_LIFT, 0, 0);
|
||||
}
|
||||
|
||||
export function getModeInputForeground(
|
||||
mode: string,
|
||||
terminalBg: string | null,
|
||||
|
||||
+10
-84
@@ -2,7 +2,6 @@ import {
|
||||
getCurrentContextSize,
|
||||
type ManagedHubBuildMismatchEvent,
|
||||
summarizeUsageFromMessages,
|
||||
upgradeManagedHub,
|
||||
watchManagedHubBuildMismatch,
|
||||
} from "@cline/core";
|
||||
import { formatDisplayUserInput } from "@cline/shared";
|
||||
@@ -24,7 +23,6 @@ import {
|
||||
} from "../utils/repo-status";
|
||||
import { buildCheckpointPickerItems } from "./checkpoint-picker-items";
|
||||
import type { TranscriptScrollHandle } from "./components/chat-message-list";
|
||||
import { DialogThemeSync } from "./components/dialog-theme-sync";
|
||||
import {
|
||||
CheckpointConfirmContent,
|
||||
type CheckpointRestoreMode,
|
||||
@@ -41,10 +39,7 @@ import {
|
||||
buildCommandPaletteItems,
|
||||
findCommandPaletteShortcut,
|
||||
} from "./components/dialogs/command-palette-items";
|
||||
import {
|
||||
HubOutdatedContent,
|
||||
HubUpdateRequiredContent,
|
||||
} from "./components/dialogs/hub-update-required";
|
||||
import { HubUpdateRequiredContent } from "./components/dialogs/hub-update-required";
|
||||
import { shouldWatchManagedHubBuild } from "./components/dialogs/hub-update-required-helpers";
|
||||
import {
|
||||
SKILLS_MARKETPLACE_ACTION,
|
||||
@@ -590,85 +585,17 @@ function App(props: TuiProps) {
|
||||
setHubBuildMismatch(null);
|
||||
const hubCoreVersion = hubBuildMismatch.hubCoreVersion;
|
||||
if (hubBuildMismatch.reason === "outdated_hub") {
|
||||
// This CLI is already the newer build; the Hub is behind only because
|
||||
// retiring it would kill the sessions it is serving. Left alone it
|
||||
// would stay behind for as long as those sessions run, so put the
|
||||
// choice to the user: replace it now (interrupting that work), or
|
||||
// keep it running and update later. This session itself is safe
|
||||
// either way - a CLI that could not attach to the outdated Hub is
|
||||
// running on the local backend.
|
||||
const details = {
|
||||
hubCoreVersion,
|
||||
activeSessionCount: hubBuildMismatch.activeSessionCount,
|
||||
participantClientCount: hubBuildMismatch.participantClientCount,
|
||||
};
|
||||
void dialog
|
||||
.choice<boolean>({
|
||||
content: (ctx: ChoiceContext<boolean>) => (
|
||||
<HubOutdatedContent {...ctx} {...details} />
|
||||
),
|
||||
})
|
||||
.then(async (update) => {
|
||||
if (!update) {
|
||||
// choice() resolves undefined on Esc; it does not reject.
|
||||
showToast(
|
||||
"The running Cline Hub stays on the older version. Run 'cline hub upgrade' once its sessions finish.",
|
||||
"info",
|
||||
);
|
||||
refocusTextareaRef.current();
|
||||
return;
|
||||
}
|
||||
showToast("Updating the Cline Hub…", "info");
|
||||
try {
|
||||
const result = await upgradeManagedHub({
|
||||
force: true,
|
||||
reason: "cline TUI hub update",
|
||||
});
|
||||
if (result.outcome === "still_busy") {
|
||||
showToast(
|
||||
"The Hub picked up new sessions before it could be replaced. Try again in a moment.",
|
||||
"info",
|
||||
);
|
||||
} else {
|
||||
showToast(
|
||||
result.outcome === "replaced" || result.outcome === "started"
|
||||
? "Cline Hub updated."
|
||||
: "Cline Hub is already up to date.",
|
||||
"success",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(
|
||||
error instanceof Error && error.message
|
||||
? error.message
|
||||
: "Updating the Cline Hub failed. Run 'cline doctor fix' and try again.",
|
||||
"error",
|
||||
);
|
||||
}
|
||||
refocusTextareaRef.current();
|
||||
})
|
||||
.catch(() => {
|
||||
refocusTextareaRef.current();
|
||||
});
|
||||
// This CLI is already the newer build. The Hub is behind only because
|
||||
// retiring it would kill the sessions it is serving, and it is
|
||||
// replaced on its own at the next launch. Nothing is wrong, nothing is
|
||||
// asked, and nothing the user can act on differs - so say nothing, the
|
||||
// same conclusion the desktop surface reached.
|
||||
//
|
||||
// The classification still earns its keep here: it is what stops the
|
||||
// update-and-restart prompt below from firing at someone who has
|
||||
// nothing to update.
|
||||
return;
|
||||
}
|
||||
if (hubBuildMismatch.reason === "build_mismatch") {
|
||||
// The Hub is newer but still speaks this CLI's protocol, so the
|
||||
// session keeps working and parity is advisable rather than urgent.
|
||||
// A modal mid-session is too heavy for advice; a toast (once per
|
||||
// observed Hub build, the watcher dedupes) says what changed and
|
||||
// how to catch up without stealing focus.
|
||||
showToast(
|
||||
`The shared Cline Hub was updated${
|
||||
hubCoreVersion ? ` (core ${hubCoreVersion})` : ""
|
||||
}. Run 'cline update' and restart when convenient.`,
|
||||
"info",
|
||||
);
|
||||
return;
|
||||
}
|
||||
// unsupported_protocol: this CLI cannot speak the running Hub's
|
||||
// protocol at all, so nothing hub-backed can work until it updates.
|
||||
// That is worth a blocking prompt.
|
||||
void dialog
|
||||
.choice<boolean>({
|
||||
content: (ctx: ChoiceContext<boolean>) => (
|
||||
@@ -1111,7 +1038,6 @@ export function Root(
|
||||
<TerminalColorsContext value={terminalColors}>
|
||||
<ThemeProvider initialThemeId={props.initialThemeId}>
|
||||
<DialogProvider size="medium">
|
||||
<DialogThemeSync />
|
||||
<SessionProvider
|
||||
config={props.config}
|
||||
initialEntries={initialEntries}
|
||||
|
||||
@@ -2,10 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { diffPalettes, themePalette } from "./palette";
|
||||
import {
|
||||
AUTO_THEME_ID,
|
||||
DEFAULT_DIALOG_SURFACE,
|
||||
getDialogAccents,
|
||||
getDialogPalette,
|
||||
getDialogSurface,
|
||||
getThemeDefinition,
|
||||
getThemeModeAccent,
|
||||
getThemeSwatchColors,
|
||||
@@ -167,66 +164,4 @@ describe("theme helpers", () => {
|
||||
expect(getThemeDefinition("gruvbox-dark")?.label).toBe("Gruvbox Dark");
|
||||
expect(getThemeDefinition("missing")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("getDialogPalette follows the theme's dialog accents", () => {
|
||||
const dracula = getDialogPalette(resolveTheme("dracula", noDetection));
|
||||
expect(dracula.act).toBe("#bd93f9");
|
||||
expect(dracula.selection).toBe("#bd93f9");
|
||||
expect(dracula.success).toBe("#50fa7b");
|
||||
expect(dracula.error).toBe("#ff5555");
|
||||
expect(dracula.textOnSelection).toBe("#000000");
|
||||
|
||||
// Light themes fall back to dark accents (dialog surfaces stay dark).
|
||||
const solarizedLight = getDialogPalette(
|
||||
resolveTheme("solarized-light", noDetection),
|
||||
);
|
||||
expect(solarizedLight.act).toBe(themePalette.dark.act);
|
||||
|
||||
for (const definition of THEMES) {
|
||||
const dialogPalette = getDialogPalette(
|
||||
resolveTheme(definition.id, noDetection),
|
||||
);
|
||||
expect(dialogPalette.selection).toBe(dialogPalette.act);
|
||||
expect(["#000000", "#ffffff"]).toContain(dialogPalette.textOnSelection);
|
||||
}
|
||||
});
|
||||
|
||||
it("getDialogSurface lifts dark theme backgrounds and keeps hue", () => {
|
||||
// Dark themes derive the panel from their own background: a different,
|
||||
// lighter color than both the background and the neutral default.
|
||||
for (const id of ["tokyo-night", "dracula", "gruvbox-dark", "nord"]) {
|
||||
const theme = resolveTheme(id, noDetection);
|
||||
const surface = getDialogSurface(theme);
|
||||
expect(surface).toMatch(/^#[0-9a-f]{6}$/i);
|
||||
expect(surface).not.toBe(theme.background);
|
||||
expect(surface).not.toBe(DEFAULT_DIALOG_SURFACE);
|
||||
}
|
||||
|
||||
// Auto with no detected background has nothing to derive from.
|
||||
expect(getDialogSurface(resolveTheme(AUTO_THEME_ID, noDetection))).toBe(
|
||||
DEFAULT_DIALOG_SURFACE,
|
||||
);
|
||||
// Auto on a detected dark terminal lifts the detected background.
|
||||
expect(
|
||||
getDialogSurface(
|
||||
resolveTheme(AUTO_THEME_ID, {
|
||||
background: "#000000",
|
||||
foreground: null,
|
||||
}),
|
||||
),
|
||||
).not.toBe(DEFAULT_DIALOG_SURFACE);
|
||||
|
||||
// Light themes keep the neutral dark panel (dialog content still uses
|
||||
// the dark accent fallback, and hardcoded light text must stay legible).
|
||||
expect(getDialogSurface(resolveTheme("light", noDetection))).toBe(
|
||||
DEFAULT_DIALOG_SURFACE,
|
||||
);
|
||||
expect(getDialogSurface(resolveTheme("solarized-light", noDetection))).toBe(
|
||||
DEFAULT_DIALOG_SURFACE,
|
||||
);
|
||||
|
||||
// The palette exposes the same surface.
|
||||
const dracula = resolveTheme("dracula", noDetection);
|
||||
expect(getDialogPalette(dracula).surface).toBe(getDialogSurface(dracula));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
diffPalettes,
|
||||
getDefaultForeground,
|
||||
getDialogSurfaceBackground,
|
||||
getTerminalTheme,
|
||||
hexToOklab,
|
||||
oklabToHex,
|
||||
@@ -568,53 +567,6 @@ export function getDialogAccents(theme: ResolvedTheme): ThemeAccents {
|
||||
return theme.variant === "dark" ? theme.accents : baseAccents.dark;
|
||||
}
|
||||
|
||||
/** Neutral dark panel used when no theme background is available to derive
|
||||
* a surface from (auto theme on an undetected terminal, light themes). */
|
||||
export const DEFAULT_DIALOG_SURFACE = "#262626";
|
||||
|
||||
/**
|
||||
* Background for dialog panels. Dark themes get their own background lifted
|
||||
* one perceptual step (OKLAB), so the panel keeps the theme's hue and reads
|
||||
* as a raised surface of the same world. Light themes and undetectable
|
||||
* backgrounds keep the neutral dark panel that matches the dark accent
|
||||
* fallback in getDialogAccents.
|
||||
*/
|
||||
export function getDialogSurface(theme: ResolvedTheme): string {
|
||||
if (theme.variant !== "dark" || !theme.background) {
|
||||
return DEFAULT_DIALOG_SURFACE;
|
||||
}
|
||||
return getDialogSurfaceBackground(theme.background);
|
||||
}
|
||||
|
||||
/**
|
||||
* Colors for content rendered on the always-dark dialog surface, following
|
||||
* the active theme's accents (see getDialogAccents). Mirrors the shape of
|
||||
* the static `palette` so dialog components can consume it as a drop-in
|
||||
* replacement that re-resolves on every theme change (including previews).
|
||||
*/
|
||||
export interface DialogPalette extends ThemeAccents {
|
||||
selection: string;
|
||||
textOnSelection: string;
|
||||
muted: string;
|
||||
/** Panel background the dialog content is rendered on. */
|
||||
surface: string;
|
||||
}
|
||||
|
||||
export function getDialogPalette(theme: ResolvedTheme): DialogPalette {
|
||||
const accents = getDialogAccents(theme);
|
||||
const selection = accents.act;
|
||||
return {
|
||||
...accents,
|
||||
selection,
|
||||
textOnSelection:
|
||||
relativeLuminance(selection) > WHITE_TEXT_LUMINANCE_CUTOFF
|
||||
? "#000000"
|
||||
: "#ffffff",
|
||||
muted: "gray",
|
||||
surface: getDialogSurface(theme),
|
||||
};
|
||||
}
|
||||
|
||||
/** Small color strip rendered next to each entry in the theme picker. */
|
||||
export function getThemeSwatchColors(definition: ThemeDefinition): string[] {
|
||||
const variant = definition.variant === "auto" ? "dark" : definition.variant;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isProviderSettingsUsable, ProviderSettingsManager } from "@cline/core";
|
||||
import { ProviderSettingsManager } from "@cline/core";
|
||||
import { isProviderSettingsUsable } from "../../utils/provider-readiness";
|
||||
import type { TuiProps } from "../types";
|
||||
|
||||
export function isProviderConfigured(config: TuiProps["config"]): boolean {
|
||||
|
||||
@@ -16,8 +16,9 @@ import {
|
||||
import type { CliCompactionMode, Config } from "../../utils/types";
|
||||
import { getMcpManagerEntryStatus } from "../components/dialogs/mcp-manager-dialog";
|
||||
import { resolveModelDisplayName } from "../components/status-bar";
|
||||
import { useDialogPalette, useThemeController } from "../hooks/use-theme";
|
||||
import { type DialogPalette, getThemeDefinition } from "../themes";
|
||||
import { useThemeController } from "../hooks/use-theme";
|
||||
import { palette } from "../palette";
|
||||
import { getDialogAccents, getThemeDefinition } from "../themes";
|
||||
import {
|
||||
type ConfigAction,
|
||||
canDeleteConfigFooterRow,
|
||||
@@ -117,13 +118,11 @@ function getVisibleWindow<T>(
|
||||
return { items: items.slice(start, end), startIndex: start };
|
||||
}
|
||||
|
||||
function getCompactionModeColor(
|
||||
mode: CliCompactionMode,
|
||||
palette: DialogPalette,
|
||||
): string {
|
||||
if (mode === "agentic") return palette.success;
|
||||
return mode === "basic" ? "yellow" : "gray";
|
||||
}
|
||||
const COMPACTION_MODE_COLORS: Record<CliCompactionMode, string> = {
|
||||
agentic: palette.success,
|
||||
basic: "yellow",
|
||||
off: "gray",
|
||||
};
|
||||
|
||||
export interface ConfigPanelProps extends ChoiceContext<ConfigAction> {
|
||||
config: Config;
|
||||
@@ -410,7 +409,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
const [toggleError, setToggleError] = useState<string | undefined>();
|
||||
const [navPos, setNavPos] = useState(0);
|
||||
const themeController = useThemeController();
|
||||
const palette = useDialogPalette();
|
||||
const dialogAccents = getDialogAccents(themeController.theme);
|
||||
const currentThemeLabel =
|
||||
getThemeDefinition(themeController.selectedThemeId)?.label ?? "Auto";
|
||||
|
||||
@@ -824,10 +823,11 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
let valueColor: string;
|
||||
if (row.id === "mode") {
|
||||
value = mode === "plan" ? "Plan" : "Act";
|
||||
valueColor = mode === "plan" ? palette.plan : palette.act;
|
||||
valueColor =
|
||||
mode === "plan" ? dialogAccents.plan : dialogAccents.act;
|
||||
} else if (row.id === "theme") {
|
||||
value = currentThemeLabel;
|
||||
valueColor = palette.act;
|
||||
valueColor = dialogAccents.act;
|
||||
} else if (row.id === "auto-approve") {
|
||||
value = autoApprove ? "● on" : "○ off";
|
||||
valueColor = autoApprove ? palette.success : "gray";
|
||||
@@ -836,7 +836,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
valueColor = autoUpdateEnabled ? palette.success : "gray";
|
||||
} else if (row.id === "compaction") {
|
||||
value = formatCliCompactionMode(compactionMode);
|
||||
valueColor = getCompactionModeColor(compactionMode, palette);
|
||||
valueColor = COMPACTION_MODE_COLORS[compactionMode];
|
||||
} else {
|
||||
value = verbose ? "● on" : "○ off";
|
||||
valueColor = verbose ? palette.success : "gray";
|
||||
|
||||
@@ -15,7 +15,7 @@ import { listSessions } from "../../session/session";
|
||||
import { mergeHistoryStatusRows } from "../../utils/history-format";
|
||||
import { formatUsd } from "../../utils/output";
|
||||
import { shouldShowCliUsageCost } from "../../utils/usage-cost-display";
|
||||
import { useDialogPalette } from "../hooks/use-theme";
|
||||
import { palette } from "../palette";
|
||||
import {
|
||||
buildHistoryFooterText,
|
||||
HISTORY_EXPORT_OPTIONS,
|
||||
@@ -98,7 +98,6 @@ function HistoryListContent({
|
||||
refreshIntervalMs = DEFAULT_REFRESH_INTERVAL_MS,
|
||||
registerKeyHandler,
|
||||
}: HistoryListContentProps) {
|
||||
const palette = useDialogPalette();
|
||||
const { width } = useTerminalDimensions();
|
||||
const [rows, setRows] = useState<SessionHistoryRecord[]>(
|
||||
() => initialRows ?? [],
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
} from "@cline/core";
|
||||
import { isClineProvider } from "@cline/shared";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { isChatProviderModel } from "../../../utils/chat-models";
|
||||
import {
|
||||
getCliSubscriptionUrl,
|
||||
getIndividualPlanFeatures,
|
||||
@@ -234,7 +233,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
const ids = new Set<string>();
|
||||
for (const result of results) {
|
||||
if (result.status !== "fulfilled") continue;
|
||||
for (const m of result.value.models.filter(isChatProviderModel)) {
|
||||
for (const m of result.value.models) {
|
||||
if (m.supportsReasoning) ids.add(m.id);
|
||||
}
|
||||
}
|
||||
@@ -284,7 +283,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
providerId,
|
||||
providerConfig,
|
||||
);
|
||||
return models.filter(isChatProviderModel).map(toModelEntry);
|
||||
return models.map(toModelEntry);
|
||||
})
|
||||
.then((models) => {
|
||||
setModelEntries(models);
|
||||
|
||||
@@ -128,31 +128,6 @@ describe("onboarding model helpers", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps non-chat models out of the onboarding model picker", () => {
|
||||
expect(
|
||||
toModelEntriesFromKnownModels({
|
||||
"operation-only-whisper": {
|
||||
name: "Operation-only Whisper",
|
||||
operation: "transcription",
|
||||
},
|
||||
"whisper-large-v3": {
|
||||
name: "Whisper Large V3",
|
||||
modalities: { input: ["audio"], output: ["text"] },
|
||||
},
|
||||
"llama-chat": {
|
||||
name: "Llama Chat",
|
||||
modalities: { input: ["text"], output: ["text"] },
|
||||
},
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
id: "llama-chat",
|
||||
name: "Llama Chat",
|
||||
supportsReasoning: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("formats OAuth provider labels for onboarding status views", () => {
|
||||
expect(getOAuthProviderLabel("cline")).toBe("Cline");
|
||||
expect(getOAuthProviderLabel("cline-pass")).toBe("ClinePass");
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
import type {
|
||||
ChatModelModalities,
|
||||
ModelModality,
|
||||
ModelOperation,
|
||||
} from "@cline/shared";
|
||||
import { isChatProviderModel } from "../../../utils/chat-models";
|
||||
import { isOpenAICodexCliProvider } from "../../../utils/codex-cli";
|
||||
import { isOAuthProvider } from "../../../utils/provider-auth";
|
||||
|
||||
@@ -157,16 +151,11 @@ export interface ProviderModelItem {
|
||||
id: string;
|
||||
name?: string;
|
||||
supportsReasoning?: boolean;
|
||||
operation?: ModelOperation;
|
||||
inputModalities?: ModelModality[];
|
||||
outputModalities?: ModelModality[];
|
||||
}
|
||||
|
||||
export interface KnownModelInfo {
|
||||
name?: string;
|
||||
capabilities?: string[];
|
||||
operation?: ModelOperation;
|
||||
modalities?: ChatModelModalities;
|
||||
}
|
||||
|
||||
export function toProviderEntry(provider: ProviderCatalogItem): ProviderEntry {
|
||||
@@ -196,13 +185,6 @@ export function toModelEntriesFromKnownModels(
|
||||
): ModelEntry[] {
|
||||
if (!knownModels) return [];
|
||||
return Object.entries(knownModels)
|
||||
.filter(([, info]) =>
|
||||
isChatProviderModel({
|
||||
operation: info.operation,
|
||||
inputModalities: info.modalities?.input,
|
||||
outputModalities: info.modalities?.output,
|
||||
}),
|
||||
)
|
||||
.map(([id, info]) => ({
|
||||
id,
|
||||
name: info.name || id,
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { createInterface } from "node:readline";
|
||||
import {
|
||||
type ToolApprovalRequest,
|
||||
type ToolApprovalResult,
|
||||
USER_REJECTED_TOOL_REASON,
|
||||
} from "@cline/shared";
|
||||
import type { ToolApprovalRequest, ToolApprovalResult } from "@cline/shared";
|
||||
import { truncate } from "./helpers";
|
||||
import { c, getActiveCliSession, write } from "./output";
|
||||
|
||||
@@ -95,7 +91,7 @@ async function requestTerminalToolApproval(
|
||||
}
|
||||
return {
|
||||
approved: false,
|
||||
reason: USER_REJECTED_TOOL_REASON,
|
||||
reason: `Tool "${request.toolName}" was denied by user`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { filterChatModels, isChatProviderModel } from "./chat-models";
|
||||
|
||||
describe("chat model filtering", () => {
|
||||
it("keeps unknown and text-capable catalog models", () => {
|
||||
const models = {
|
||||
legacy: { name: "Legacy" },
|
||||
chat: {
|
||||
name: "Chat",
|
||||
modalities: { input: ["text"] as const, output: ["text"] as const },
|
||||
},
|
||||
mixed: {
|
||||
name: "Mixed",
|
||||
modalities: {
|
||||
input: ["text", "image"] as const,
|
||||
output: ["text", "image"] as const,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(Object.keys(filterChatModels(models))).toEqual([
|
||||
"legacy",
|
||||
"chat",
|
||||
"mixed",
|
||||
]);
|
||||
});
|
||||
|
||||
it("removes dedicated transcription and media-generation models", () => {
|
||||
const models = {
|
||||
operationOnly: { operation: "speech-generation" as const },
|
||||
whisper: {
|
||||
modalities: { input: ["audio"] as const, output: ["text"] as const },
|
||||
},
|
||||
tts: {
|
||||
modalities: { input: ["text"] as const, output: ["audio"] as const },
|
||||
},
|
||||
};
|
||||
|
||||
expect(filterChatModels(models)).toEqual({});
|
||||
});
|
||||
|
||||
it("filters flattened provider model responses with the same rule", () => {
|
||||
expect(isChatProviderModel({ operation: "transcription" })).toBe(false);
|
||||
expect(
|
||||
isChatProviderModel({
|
||||
inputModalities: ["audio"],
|
||||
outputModalities: ["text"],
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isChatProviderModel({
|
||||
inputModalities: ["text", "image"],
|
||||
outputModalities: ["text"],
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(isChatProviderModel({})).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
import {
|
||||
type ChatModelModalities,
|
||||
isChatCompatibleModel,
|
||||
type ModelModality,
|
||||
type ModelOperation,
|
||||
} from "@cline/shared";
|
||||
|
||||
export type ChatCatalogModel = {
|
||||
readonly operation?: ModelOperation;
|
||||
readonly modalities?: ChatModelModalities;
|
||||
readonly [key: string]: unknown;
|
||||
};
|
||||
|
||||
export function filterChatModels<T extends ChatCatalogModel>(
|
||||
models: Readonly<Record<string, T>>,
|
||||
): Record<string, T> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(models).filter(([, model]) => isChatCompatibleModel(model)),
|
||||
);
|
||||
}
|
||||
|
||||
export function isChatProviderModel(model: {
|
||||
readonly operation?: ModelOperation;
|
||||
readonly inputModalities?: readonly ModelModality[];
|
||||
readonly outputModalities?: readonly ModelModality[];
|
||||
}): boolean {
|
||||
return isChatCompatibleModel({
|
||||
operation: model.operation,
|
||||
modalities: {
|
||||
input: model.inputModalities,
|
||||
output: model.outputModalities,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -18,11 +18,20 @@ import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
|
||||
export { getClineOrgIndividualInferenceSubscriptionMessage };
|
||||
|
||||
export const CLI_PROMO_CODE = "";
|
||||
|
||||
export function getCliSubscriptionUrl(): string {
|
||||
return new URL(
|
||||
`/dashboard/subscription?personal=true`,
|
||||
if (!CLI_PROMO_CODE) {
|
||||
return new URL(
|
||||
`/dashboard/subscription?personal=true`,
|
||||
getClineEnvironmentConfig().appBaseUrl,
|
||||
).toString();
|
||||
}
|
||||
|
||||
return `${new URL(
|
||||
`/promo?code=${CLI_PROMO_CODE}&personal=true`,
|
||||
getClineEnvironmentConfig().appBaseUrl,
|
||||
).toString();
|
||||
).toString()}`;
|
||||
}
|
||||
|
||||
export function getCliNotSubscribedMessage(): string {
|
||||
|
||||
+1
-21
@@ -1,5 +1,5 @@
|
||||
import type { ProviderSettings } from "@cline/core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ProviderSettings } from "../llms/provider-settings";
|
||||
import { isProviderSettingsUsable } from "./provider-readiness";
|
||||
|
||||
describe("provider readiness", () => {
|
||||
@@ -167,24 +167,4 @@ describe("provider readiness", () => {
|
||||
} satisfies ProviderSettings),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects migrated placeholder entries that only hold a default model", () => {
|
||||
// The legacy VS Code migration can seed entries with a catalog-default
|
||||
// model and no credentials (e.g. qwen-code from a plan/act provider
|
||||
// selection, sapaicore from a persisted orchestration-mode default).
|
||||
// These must not read as configured.
|
||||
expect(
|
||||
isProviderSettingsUsable("qwen-code", {
|
||||
provider: "qwen-code",
|
||||
model: "qwen3-coder-plus",
|
||||
} satisfies ProviderSettings),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isProviderSettingsUsable("sapaicore", {
|
||||
provider: "sapaicore",
|
||||
model: "anthropic--claude-4-sonnet",
|
||||
sap: { useOrchestrationMode: true },
|
||||
} satisfies ProviderSettings),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
+10
-20
@@ -1,13 +1,13 @@
|
||||
import * as LlmsModels from "@cline/llms";
|
||||
import {
|
||||
getProviderConfigFields,
|
||||
type ProviderConfig,
|
||||
type ProviderSettings,
|
||||
} from "@cline/core";
|
||||
import {
|
||||
getPersistedProviderApiKey,
|
||||
isOAuthProvider,
|
||||
} from "../../auth/provider-auth-registry";
|
||||
import type {
|
||||
ProviderConfig,
|
||||
ProviderSettings,
|
||||
} from "../llms/provider-settings";
|
||||
import { getProviderConfigFields } from "./provider-config-fields";
|
||||
normalizeProviderId,
|
||||
} from "./provider-auth";
|
||||
|
||||
function hasText(value: string | undefined): boolean {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
@@ -34,7 +34,7 @@ function hasAwsRegion(settings: ProviderSettings): boolean {
|
||||
function hasGcpCredentials(settings: ProviderSettings): boolean {
|
||||
const gcp = settings.gcp;
|
||||
// Vertex defaults to us-central1 at runtime when no region is stored, so keep
|
||||
// existing project-only configs usable while new saves include a region.
|
||||
// existing project-only configs usable while new CLI saves include a region.
|
||||
return hasText(gcp?.projectId);
|
||||
}
|
||||
|
||||
@@ -52,14 +52,6 @@ function hasSapCredentials(settings: ProviderSettings): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether persisted provider settings hold enough real credentials or
|
||||
* endpoint configuration for a turn to plausibly succeed. Unlike the mere
|
||||
* existence of a settings entry (which migrations and empty "connect" saves
|
||||
* can create), this requires provider-appropriate evidence: an API key or
|
||||
* OAuth token, cloud credentials (AWS/GCP/Azure/SAP), a local-auth CLI, or a
|
||||
* resolvable endpoint + model for keyless local providers.
|
||||
*/
|
||||
export function isProviderSettingsUsable(
|
||||
providerId: string,
|
||||
settings: ProviderSettings | undefined,
|
||||
@@ -68,10 +60,8 @@ export function isProviderSettingsUsable(
|
||||
if (!settings) {
|
||||
return false;
|
||||
}
|
||||
const normalizedProviderId = LlmsModels.normalizeProviderId(providerId);
|
||||
if (
|
||||
LlmsModels.normalizeProviderId(settings.provider) !== normalizedProviderId
|
||||
) {
|
||||
const normalizedProviderId = normalizeProviderId(providerId);
|
||||
if (normalizeProviderId(settings.provider) !== normalizedProviderId) {
|
||||
return false;
|
||||
}
|
||||
if (normalizedProviderId === "bedrock") {
|
||||
@@ -1,6 +1,6 @@
|
||||
import { dirname, join, normalize } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { ProviderSettingsManager } from "@cline/core";
|
||||
import { ClientSettingsManager, ProviderSettingsManager } from "@cline/core";
|
||||
import { buildInviteUrl, resolveClineHubServerOptions } from "../options";
|
||||
import type { BrowserConfig } from "./types";
|
||||
|
||||
@@ -19,6 +19,12 @@ export const cliIndexPath = normalize(
|
||||
);
|
||||
|
||||
export const providerSettingsManager = new ProviderSettingsManager();
|
||||
export const desktopClientSettingsManager = new ClientSettingsManager({
|
||||
clientId: "desktop",
|
||||
});
|
||||
desktopClientSettingsManager.initializeModesIfMissing(
|
||||
providerSettingsManager.read().modes,
|
||||
);
|
||||
|
||||
export const browserConfig: BrowserConfig = {
|
||||
inviteRequired: Boolean(roomSecret),
|
||||
|
||||
@@ -17,19 +17,25 @@ import {
|
||||
type ProviderClient,
|
||||
type ProviderProtocol,
|
||||
type ProviderSettings,
|
||||
parseProviderModeSettings,
|
||||
readGlobalSettings,
|
||||
saveLocalProviderOAuthCredentials,
|
||||
saveLocalProviderSettings,
|
||||
saveModeSettings,
|
||||
setAutoUpdateEnabledGlobally,
|
||||
setTelemetryOptOutGlobally,
|
||||
} from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import { getClineEnvironmentConfig, ProviderModeSchema } from "@cline/shared";
|
||||
import {
|
||||
connectorChannelsPayload,
|
||||
startConnectorChannel,
|
||||
stopConnectorChannel,
|
||||
} from "./connectors";
|
||||
import { providerSettingsManager, workspaceRoot } from "./deps";
|
||||
import {
|
||||
desktopClientSettingsManager,
|
||||
providerSettingsManager,
|
||||
workspaceRoot,
|
||||
} from "./deps";
|
||||
import {
|
||||
installMarketplaceEntryForDesktopCommand,
|
||||
listMarketplaceInstalledEntries,
|
||||
@@ -103,6 +109,7 @@ export async function handleDesktopCommand(
|
||||
await ensureCustomProvidersLoaded(providerSettingsManager);
|
||||
return await listLocalProviders(providerSettingsManager, {
|
||||
isClinePassEnabled: true,
|
||||
modeSettings: desktopClientSettingsManager.read().modes,
|
||||
});
|
||||
}
|
||||
if (command === "list_provider_models") {
|
||||
@@ -112,6 +119,18 @@ export async function handleDesktopCommand(
|
||||
providerSettingsManager.getProviderConfig(provider),
|
||||
);
|
||||
}
|
||||
if (command === "save_mode_settings") {
|
||||
const mode = ProviderModeSchema.parse(args?.mode);
|
||||
const settings =
|
||||
args?.settings == null
|
||||
? undefined
|
||||
: parseProviderModeSettings(mode, args.settings);
|
||||
return await saveModeSettings(
|
||||
providerSettingsManager,
|
||||
{ mode, settings },
|
||||
desktopClientSettingsManager,
|
||||
);
|
||||
}
|
||||
if (command === "save_provider_settings") {
|
||||
return saveLocalProviderSettings(providerSettingsManager, {
|
||||
...readProviderSettingsUpdate(args),
|
||||
@@ -253,7 +272,7 @@ export async function handleDesktopCommand(
|
||||
return path;
|
||||
}
|
||||
if (ROUTINE_SCHEDULE_COMMANDS.has(command)) {
|
||||
return await handleRoutineScheduleCommand(command, args, workspaceRoot);
|
||||
return await handleRoutineScheduleCommand(command, args);
|
||||
}
|
||||
if (command === "get_process_context") {
|
||||
return { workspaceRoot, cwd: workspaceRoot };
|
||||
@@ -264,19 +283,6 @@ export async function handleDesktopCommand(
|
||||
) {
|
||||
return [...ctx.sessions.values()].map(toWebviewSessionSummary);
|
||||
}
|
||||
if (command === "search_sessions") {
|
||||
if (!ctx.uiClient) throw new Error("Hub is not connected");
|
||||
const query = String(args?.query ?? "").trim();
|
||||
if (!query) return [];
|
||||
return await ctx.uiClient.searchSessions({
|
||||
query,
|
||||
limit: typeof args?.limit === "number" ? args.limit : 50,
|
||||
workspaceRoot:
|
||||
typeof args?.workspaceRoot === "string"
|
||||
? args.workspaceRoot
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
if (command === "read_session_hooks") {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import process from "node:process";
|
||||
import {
|
||||
ensureCustomProvidersLoaded,
|
||||
getLocalProviderModels,
|
||||
isChatProviderModel,
|
||||
Llms,
|
||||
listLocalProviders,
|
||||
loginAndSaveLocalProviderOAuthCredentials,
|
||||
@@ -9,7 +10,6 @@ import {
|
||||
normalizeOAuthProvider,
|
||||
saveLocalProviderSettings,
|
||||
} from "@cline/core";
|
||||
import { isChatCompatibleModel } from "@cline/shared";
|
||||
import type {
|
||||
WebviewInboundMessage,
|
||||
WebviewProviderModel,
|
||||
@@ -88,19 +88,10 @@ export async function loadModels(
|
||||
providerSettingsManager.getProviderConfig(provider),
|
||||
);
|
||||
const models: WebviewProviderModel[] = payload.models
|
||||
.filter((model) =>
|
||||
isChatCompatibleModel({
|
||||
operation: model.operation,
|
||||
modalities: {
|
||||
input: model.inputModalities,
|
||||
output: model.outputModalities,
|
||||
},
|
||||
}),
|
||||
)
|
||||
.filter(isChatProviderModel)
|
||||
.map((model) => ({
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
operation: model.operation,
|
||||
supportsReasoning: model.supportsReasoning,
|
||||
supportsThinking: model.supportsReasoning,
|
||||
inputModalities: model.inputModalities,
|
||||
|
||||
@@ -27,20 +27,13 @@ function getCommands(): HubScheduleCommandService {
|
||||
async function clientCommand(
|
||||
hubCommand: string,
|
||||
payload?: Record<string, unknown>,
|
||||
workspaceRoot = process.cwd(),
|
||||
): Promise<Record<string, unknown>> {
|
||||
const reply = await getCommands().handleCommand(
|
||||
{
|
||||
version: "v1",
|
||||
clientId: "cline-hub-schedules",
|
||||
command: hubCommand as never,
|
||||
payload,
|
||||
},
|
||||
{
|
||||
clientId: "cline-hub-schedules",
|
||||
workspaceContext: { workspaceRoot, cwd: workspaceRoot },
|
||||
},
|
||||
);
|
||||
const reply = await getCommands().handleCommand({
|
||||
version: "v1",
|
||||
clientId: "cline-hub-schedules",
|
||||
command: hubCommand as never,
|
||||
payload,
|
||||
});
|
||||
if (!reply.ok) {
|
||||
throw new Error(
|
||||
reply.error?.message ?? `hub command failed: ${hubCommand}`,
|
||||
@@ -77,17 +70,14 @@ function asTrimmedStringArray(value: unknown): string[] | undefined {
|
||||
export async function handleRoutineScheduleCommand(
|
||||
command: string,
|
||||
args?: Record<string, unknown>,
|
||||
workspaceRoot = process.cwd(),
|
||||
): Promise<unknown> {
|
||||
const commandHub = (hubCommand: string, payload?: Record<string, unknown>) =>
|
||||
clientCommand(hubCommand, payload, workspaceRoot);
|
||||
if (command === "list_routine_schedules") {
|
||||
const [schedules, activeExecutions, upcomingRuns] = await Promise.all([
|
||||
commandHub("schedule.list", {
|
||||
clientCommand("schedule.list", {
|
||||
limit: toPositiveInt(args?.limit) ?? 200,
|
||||
}),
|
||||
commandHub("schedule.active"),
|
||||
commandHub("schedule.upcoming", { limit: 30 }),
|
||||
clientCommand("schedule.active"),
|
||||
clientCommand("schedule.upcoming", { limit: 30 }),
|
||||
]);
|
||||
const scheduleRows = Array.isArray(schedules.schedules)
|
||||
? schedules.schedules
|
||||
@@ -98,7 +88,7 @@ export async function handleRoutineScheduleCommand(
|
||||
(schedule as Record<string, unknown>).scheduleId,
|
||||
);
|
||||
if (!scheduleId) return undefined;
|
||||
const reply = await commandHub("schedule.list_executions", {
|
||||
const reply = await clientCommand("schedule.list_executions", {
|
||||
scheduleId,
|
||||
limit: 1,
|
||||
});
|
||||
@@ -125,7 +115,7 @@ export async function handleRoutineScheduleCommand(
|
||||
"createSchedule requires name, timing, prompt, and workspace_root",
|
||||
);
|
||||
}
|
||||
const created = await commandHub("schedule.create", {
|
||||
const created = await clientCommand("schedule.create", {
|
||||
name,
|
||||
...timing,
|
||||
prompt,
|
||||
@@ -159,7 +149,7 @@ export async function handleRoutineScheduleCommand(
|
||||
"updateSchedule requires schedule_id, name, timing, prompt, and workspace_root",
|
||||
);
|
||||
}
|
||||
const reply = await commandHub("schedule.update", {
|
||||
const reply = await clientCommand("schedule.update", {
|
||||
scheduleId,
|
||||
name,
|
||||
...timing,
|
||||
@@ -190,25 +180,25 @@ export async function handleRoutineScheduleCommand(
|
||||
return { schedule: reply.schedule ?? null };
|
||||
}
|
||||
if (command === "pause_routine_schedule") {
|
||||
const reply = await commandHub("schedule.disable", { scheduleId });
|
||||
const reply = await clientCommand("schedule.disable", { scheduleId });
|
||||
return { schedule: reply.schedule ?? null };
|
||||
}
|
||||
if (command === "resume_routine_schedule") {
|
||||
const reply = await commandHub("schedule.enable", { scheduleId });
|
||||
const reply = await clientCommand("schedule.enable", { scheduleId });
|
||||
return { schedule: reply.schedule ?? null };
|
||||
}
|
||||
if (command === "trigger_routine_schedule") {
|
||||
const existing = await commandHub("schedule.get", { scheduleId });
|
||||
const existing = await clientCommand("schedule.get", { scheduleId });
|
||||
if (!existing.schedule)
|
||||
throw new Error(`schedule not found: ${scheduleId}`);
|
||||
const reply = await commandHub("schedule.trigger", {
|
||||
const reply = await clientCommand("schedule.trigger", {
|
||||
scheduleId,
|
||||
wait: false,
|
||||
});
|
||||
return { execution: reply.execution ?? null };
|
||||
}
|
||||
if (command === "delete_routine_schedule") {
|
||||
const reply = await commandHub("schedule.delete", { scheduleId });
|
||||
const reply = await clientCommand("schedule.delete", { scheduleId });
|
||||
return { deleted: reply.deleted === true };
|
||||
}
|
||||
throw new Error(`unsupported routine schedule command: ${command}`);
|
||||
|
||||
@@ -88,7 +88,7 @@ function summarizeClient(client: TrackedClient): {
|
||||
normalizedType === "code-sidecar-observer" ||
|
||||
normalizedType === "code-sidecar-list"
|
||||
) {
|
||||
return { key: "code-app", label: "Cline Desktop", name: "Cline Desktop" };
|
||||
return { key: "code-app", label: "Code App", name: "Code App" };
|
||||
}
|
||||
return {
|
||||
key: client.clientId,
|
||||
|
||||
@@ -15,12 +15,7 @@ export type WebviewUsage = {
|
||||
|
||||
export type WebviewProviderModel = Pick<
|
||||
ProviderModel,
|
||||
| "id"
|
||||
| "name"
|
||||
| "operation"
|
||||
| "supportsReasoning"
|
||||
| "inputModalities"
|
||||
| "outputModalities"
|
||||
"id" | "name" | "supportsReasoning" | "inputModalities" | "outputModalities"
|
||||
> & {
|
||||
supportsThinking?: boolean;
|
||||
};
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
"media-chrome": "^4.18.1",
|
||||
"mermaid": "11.16.1",
|
||||
"mermaid": "11.16.0",
|
||||
"motion": "^12.38.0",
|
||||
"nanoid": "^5.1.7",
|
||||
"next-themes": "^0.4.6",
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
PlugIcon,
|
||||
RotateCcwIcon,
|
||||
RssIcon,
|
||||
SearchIcon,
|
||||
ServerIcon,
|
||||
SettingsIcon,
|
||||
Trash2Icon,
|
||||
@@ -64,7 +63,6 @@ import type {
|
||||
import { PageFrame, PageHeader } from "./components/views/page-layout";
|
||||
import type { CustomizationSection } from "./components/views/settings/extensions-view";
|
||||
import type { SettingsSection } from "./components/views/settings/settings-view";
|
||||
import { desktopClient } from "./lib/desktop-client";
|
||||
import { syncHubTheme } from "./lib/theme";
|
||||
import { postToHost } from "./vscode";
|
||||
|
||||
@@ -742,18 +740,7 @@ function SessionsView({
|
||||
onRenameSession: (sessionId: string, title: string) => Promise<void> | void;
|
||||
sessions: WebviewSessionSummary[];
|
||||
}) {
|
||||
type SearchHit = {
|
||||
sessionId: string;
|
||||
documentId: string;
|
||||
title: string;
|
||||
workspaceRoot: string;
|
||||
role: string;
|
||||
snippet: string;
|
||||
};
|
||||
const [sessionFilters, setSessionFilters] = useState<string[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchHits, setSearchHits] = useState<SearchHit[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
|
||||
const [editingTitle, setEditingTitle] = useState("");
|
||||
const [deleteSessionCandidate, setDeleteSessionCandidate] =
|
||||
@@ -785,34 +772,6 @@ function SessionsView({
|
||||
});
|
||||
}, [sessions, sessionFilters, sortDirection]);
|
||||
|
||||
useEffect(() => {
|
||||
const query = searchQuery.trim();
|
||||
if (!query) {
|
||||
setSearchHits([]);
|
||||
setSearching(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setSearching(true);
|
||||
const timer = setTimeout(() => {
|
||||
void desktopClient
|
||||
.invoke<SearchHit[]>("search_sessions", { query, limit: 50 })
|
||||
.then((hits) => {
|
||||
if (!cancelled) setSearchHits(hits);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setSearchHits([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setSearching(false);
|
||||
});
|
||||
}, 200);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [searchQuery]);
|
||||
|
||||
const startRenameSession = (session: WebviewSessionSummary) => {
|
||||
setEditingSessionId(session.sessionId);
|
||||
setEditingTitle(session.title || shortId(session.sessionId));
|
||||
@@ -935,52 +894,6 @@ function SessionsView({
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<div className="relative mb-3">
|
||||
<SearchIcon className="pointer-events-none absolute left-3 top-2.5 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label="Search all session history"
|
||||
className="pl-9"
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
placeholder="Search messages, commands, errors, and file paths across all sessions…"
|
||||
value={searchQuery}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{searchQuery.trim() ? (
|
||||
<section className="mb-4 overflow-hidden rounded-lg border bg-card">
|
||||
{searching ? (
|
||||
<p className="px-4 py-5 text-sm text-muted-foreground">
|
||||
Searching…
|
||||
</p>
|
||||
) : searchHits.length === 0 ? (
|
||||
<p className="px-4 py-5 text-sm text-muted-foreground">
|
||||
No matching session history.
|
||||
</p>
|
||||
) : (
|
||||
searchHits.map((hit) => (
|
||||
<button
|
||||
className="block w-full border-b px-4 py-3 text-left last:border-b-0 hover:bg-accent/40"
|
||||
key={hit.documentId}
|
||||
onClick={() => onOpenSession(hit.sessionId)}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<span className="truncate">{hit.title}</span>
|
||||
<span className="text-xs font-normal text-muted-foreground">
|
||||
{hit.role}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">
|
||||
{hit.snippet}
|
||||
</p>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{hit.workspaceRoot}
|
||||
</p>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="w-full min-w-0 overflow-x-auto">
|
||||
<div className="grid w-full min-w-[56rem] grid-cols-[minmax(12rem,1.35fr)_minmax(7rem,0.85fr)_minmax(10rem,1.1fr)_5rem_5rem_4.5rem_5.5rem_2rem] gap-x-4 bg-muted/40 px-4 py-3 text-[15px] font-medium text-muted-foreground">
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type GeneratedMedia,
|
||||
USER_REJECTED_TOOL_REASON,
|
||||
} from "@cline/shared/browser";
|
||||
import type { GeneratedMedia } from "@cline/shared/browser";
|
||||
import { GeneratedMediaContent } from "@cline/ui";
|
||||
import {
|
||||
CheckIcon,
|
||||
@@ -1185,7 +1182,7 @@ export default function Chat({
|
||||
type: "approval_response",
|
||||
approvalId,
|
||||
approved,
|
||||
reason: approved ? "Approved in Cline Hub." : USER_REJECTED_TOOL_REASON,
|
||||
reason: approved ? "Approved in Cline Hub." : "Rejected in Cline Hub.",
|
||||
});
|
||||
setStatus(approved ? "Approval sent." : "Rejection sent.");
|
||||
};
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildProviderModelCatalog } from "./provider-model-catalog";
|
||||
import type { Provider } from "./provider-schema";
|
||||
|
||||
function provider(modelList: NonNullable<Provider["modelList"]>): Provider {
|
||||
return {
|
||||
id: "test",
|
||||
name: "Test",
|
||||
models: modelList.length,
|
||||
color: "#000000",
|
||||
letter: "T",
|
||||
enabled: true,
|
||||
modelList,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildProviderModelCatalog", () => {
|
||||
it("keeps legacy and mixed chat models while excluding dedicated media endpoints", () => {
|
||||
const catalog = buildProviderModelCatalog([
|
||||
provider([
|
||||
{ id: "legacy", name: "Legacy" },
|
||||
{
|
||||
id: "operation-only-transcription",
|
||||
name: "Operation-only Transcription",
|
||||
operation: "transcription",
|
||||
},
|
||||
{
|
||||
id: "mixed",
|
||||
name: "Mixed",
|
||||
inputModalities: ["text", "image"],
|
||||
outputModalities: ["text", "image"],
|
||||
supportsReasoning: true,
|
||||
},
|
||||
{
|
||||
id: "transcription",
|
||||
name: "Transcription",
|
||||
inputModalities: ["audio"],
|
||||
outputModalities: ["text"],
|
||||
},
|
||||
{
|
||||
id: "image",
|
||||
name: "Image",
|
||||
inputModalities: ["text"],
|
||||
outputModalities: ["image"],
|
||||
},
|
||||
{
|
||||
id: "image-operation",
|
||||
name: "Image Operation",
|
||||
operation: "image-generation",
|
||||
},
|
||||
]),
|
||||
]);
|
||||
|
||||
expect(catalog.enabledProviderIds).toEqual(["test"]);
|
||||
expect(catalog.providerModels.test).toEqual(["legacy", "mixed"]);
|
||||
expect(catalog.providerReasoningModels.test).toEqual(["mixed"]);
|
||||
});
|
||||
|
||||
it("omits enabled providers with no chat-compatible models", () => {
|
||||
const catalog = buildProviderModelCatalog([
|
||||
provider([
|
||||
{
|
||||
id: "speech",
|
||||
name: "Speech",
|
||||
inputModalities: ["text"],
|
||||
outputModalities: ["audio"],
|
||||
},
|
||||
]),
|
||||
]);
|
||||
|
||||
expect(catalog.enabledProviderIds).toEqual([]);
|
||||
expect(catalog.providerModels.test).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { isChatCompatibleModel } from "@cline/shared";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import type {
|
||||
Provider,
|
||||
@@ -18,14 +17,12 @@ export type ProviderModelCatalog = {
|
||||
|
||||
function toModelIds(models: ProviderModel[] | undefined): string[] {
|
||||
return (models ?? [])
|
||||
.filter((model) =>
|
||||
isChatCompatibleModel({
|
||||
operation: model.operation,
|
||||
modalities: {
|
||||
input: model.inputModalities,
|
||||
output: model.outputModalities,
|
||||
},
|
||||
}),
|
||||
.filter(
|
||||
(model) =>
|
||||
(model.inputModalities === undefined ||
|
||||
model.inputModalities.includes("text")) &&
|
||||
(model.outputModalities === undefined ||
|
||||
model.outputModalities.includes("text")),
|
||||
)
|
||||
.map((model) => model.id);
|
||||
}
|
||||
@@ -34,14 +31,11 @@ function toReasoningModelIds(models: ProviderModel[] | undefined): string[] {
|
||||
return (models ?? [])
|
||||
.filter(
|
||||
(model) =>
|
||||
model.supportsReasoning &&
|
||||
isChatCompatibleModel({
|
||||
operation: model.operation,
|
||||
modalities: {
|
||||
input: model.inputModalities,
|
||||
output: model.outputModalities,
|
||||
},
|
||||
}),
|
||||
(model.inputModalities === undefined ||
|
||||
model.inputModalities.includes("text")) &&
|
||||
(model.outputModalities === undefined ||
|
||||
model.outputModalities.includes("text")) &&
|
||||
model.supportsReasoning,
|
||||
)
|
||||
.map((model) => model.id);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ModelModality, ModelOperation } from "@cline/shared";
|
||||
import type { ProviderModesSettings } from "@cline/shared/browser";
|
||||
|
||||
export interface ProviderModel {
|
||||
id: string;
|
||||
@@ -6,11 +6,12 @@ export interface ProviderModel {
|
||||
supportsAttachments?: boolean;
|
||||
supportsVision?: boolean;
|
||||
supportsReasoning?: boolean;
|
||||
operation?: ModelOperation;
|
||||
inputModalities?: ModelModality[];
|
||||
outputModalities?: ModelModality[];
|
||||
}
|
||||
|
||||
export type ModelModality = "text" | "image" | "audio" | "video" | "pdf";
|
||||
|
||||
export type ProviderConfigFieldType =
|
||||
| "text"
|
||||
| "password"
|
||||
@@ -68,6 +69,7 @@ export interface ProviderSettingsUpdate {
|
||||
export interface ProviderCatalogResponse {
|
||||
providers: Provider[];
|
||||
settingsPath: string;
|
||||
modes: ProviderModesSettings;
|
||||
}
|
||||
|
||||
export interface ProviderModelsResponse {
|
||||
|
||||
@@ -8,10 +8,6 @@ export default defineConfig({
|
||||
root: rootDir,
|
||||
resolve: {
|
||||
alias: [
|
||||
{
|
||||
find: /^@\/(.+)$/,
|
||||
replacement: resolve(rootDir, "src/webview/src/$1"),
|
||||
},
|
||||
{
|
||||
find: /^@cline\/core$/,
|
||||
replacement: resolve(rootDir, "../../sdk/packages/core/src/index.ts"),
|
||||
|
||||
@@ -1,133 +1,13 @@
|
||||
# Cline Desktop Changelog
|
||||
# Cline Code Desktop Changelog
|
||||
|
||||
## 0.0.21
|
||||
## 0.0.14-beta.1
|
||||
|
||||
- Marketplace is now a two-pane explorer: a browsable list on the left and full catalog metadata for the selected item on the right, with category tag filters that collapse behind a "more" toggle
|
||||
- Stopping a session now actually stops everything it started. Stop stays available while child agents are running, and an abort propagates to delegated subagents and to teammates instead of leaving orphaned work running in the background; cancelled teammate tasks now persist as cancelled
|
||||
- Fixed the ask-a-question tool's option text overflowing instead of wrapping
|
||||
- You can now drop file attachments anywhere over the chat input, not just on the small attach target
|
||||
- Cline provider models now refresh from the live catalog, so newly released models show up without waiting for an app update
|
||||
- Provider 401/403 responses are now classified as authentication errors rather than generic request failures, so a bad or missing API key is distinguishable from a real provider outage
|
||||
- Fixed Langfuse tracing never initializing in release builds — the minified bundle broke tracer detection, so telemetry worked in dev and silently did nothing in the shipped app. Also updated for AI SDK 7's telemetry API
|
||||
- Refreshed the model catalog. Adds TokenGo and Volcengine Ark, and updates model lists, pricing, and the resolved default model for ~36 providers (including Hugging Face, Mistral, OpenRouter, Together, NanoGPT, Requesty, Baseten, Cloudflare Workers AI, and DigitalOcean) — if you use one of those without pinning a model, you will get a different default
|
||||
|
||||
## 0.0.20
|
||||
|
||||
- Cline Desktop now ships on Windows: releases include a code-signed x64 installer, and installed apps auto-update on the same feed macOS does
|
||||
- Windows shell fixes: background processes (the sidecar, git) no longer pop visible console windows; updates now download in the background and install when you restart the app; the MCP settings path falls back to `USERPROFILE` when `HOME` is unset
|
||||
- Tool results that return images — screenshots from browser or MCP tools — now render as inline images you can click to expand, with a carousel for stepping through multiple images, instead of raw base64 text
|
||||
- Session search now covers your full indexed history. The sidebar search icon opens the command bar (Cmd/Ctrl+P) with server-ranked results, instead of a sidebar-local dialog that first loaded every session into memory
|
||||
- Onboarding has a new GitHub integration step
|
||||
- Fixed scheduled tasks disappearing after the app updated — hub-managed schedules were being wiped by cron reconciliation on restart
|
||||
- Agent-created schedules now live in one user-level home (`~/.cline/schedules`) instead of being scattered across whichever chat folder created them, and they now appear on the Schedules page
|
||||
- A finished scheduled session now surfaces its final answer: the completing step auto-expands, is labeled "Scheduled task completed" (or failed), and its summary renders as markdown
|
||||
- Suggested routine templates now ask for a specific final report, so a scheduled run ends with something readable
|
||||
- Providers no longer show as "Configured" on the strength of a leftover settings entry with no real credentials, and the badge now updates live after connecting or saving credentials instead of waiting for a remount
|
||||
- Fixed OpenAI Codex (ChatGPT subscription) sign-in silently dead-ending when callback port 1455 was already in use — it now fails immediately with an actionable error, and OAuth redirect errors surface instead of a confusing "Missing authorization code"
|
||||
- Codex and OCA sign-ins are no longer dropped when a token refresh hits a transient network failure or server error
|
||||
- Checkpoint restore now refuses to reset your workspace when commits were made after the checkpoint, instead of silently knocking them off the branch
|
||||
- Fixed an enabled-but-offline remote MCP server stalling session startup until the session was torn down
|
||||
- Global rules stored at `~/Cline/Rules` are now discovered (previously only `~/Documents/Cline/Rules`), fixing rules that never reached the model on WSL and headless installs
|
||||
- `apply_patch` now preserves a file's own CRLF line endings
|
||||
- The window title bar stays draggable across every view
|
||||
- Voice input's Live and After recording badges now have tooltips explaining them
|
||||
- Removed the box shadow from the chat message actions row
|
||||
- The hub no longer watches agenda spec directories while the todo tool is disabled, dropping an OS watch handle per known workspace
|
||||
|
||||
## 0.0.19
|
||||
|
||||
- Fixed the background Cline process ballooning in memory during long sessions — session status updates were carrying a full copy of the conversation transcript to every connected client, which on a multi-megabyte task could grow the process to tens of gigabytes. Status updates now carry only state (status, usage, model, workspace, checkpoint); the transcript is fetched on demand
|
||||
- Refreshed the model catalog. Adds seven providers (Agnes AI, Aixy, IteraCompute, LLM Tech, NeoSmith, Pendra, and Standard Compute) and updates model lists and pricing across providers. The resolved default model changes for ClinePass (now GLM 5.3), Z.ai, Hugging Face, evroc, LLM Gateway, NanoGPT, and Weights & Biases, so if you use one of those without pinning a model you will get a different default
|
||||
|
||||
## 0.0.18
|
||||
|
||||
- The sidebar is time-sorted again by default, with collapsible Pinned / Scheduled / Tasks sections and a one-click toggle to switch to project grouping (the old dropdown is gone). Scheduled sessions are marked with a clock icon, and the list starts taller and grows to fill the sidebar instead of stranding rows over empty space
|
||||
- Session rows now show a trash button on hover for quick deletion, with the same confirmation the row's context menu uses
|
||||
- Customize is now your installed inventory only. Browsing moved to a dedicated Marketplace page — one list across plugins, MCP servers, and skills with type-filter and tag chips — and the two pages link to each other from their headers and from sidebar sub-tabs
|
||||
- Schedule cards are now click targets: clicking a card anywhere outside its controls opens its details, the redundant eye button is gone, and the edit / run / pause / delete buttons are large enough to hit
|
||||
- Schedule details are one scrollable view instead of Overview/Runs tabs, showing the meta grid, the configuration, and the most recent runs with a "Show all N runs" expander
|
||||
- "Run now" now hands you into the session it starts
|
||||
- Scheduled and automation runs no longer render their internal `[SYSTEM]` steering messages as if you had typed them — a finished scheduled session reads as prompt, work summary, answer
|
||||
- Fixed opening a scheduled session while it runs leaving it stuck on the thinking shimmer until you switched away and back
|
||||
- Fixed installing plugins and MCP servers from the Marketplace failing with `Executable not found in $PATH: "cline"` — installs now run in-process and no longer require a Cline CLI on your machine
|
||||
- Fixed quitting the app beach-balling for several seconds
|
||||
- Cost estimates are no longer shown for subscription-billed providers (ClinePass, ChatGPT via Codex, and Claude Code), where an API-rate dollar figure read as a real charge on top of your subscription
|
||||
- Fixed hover cards flashing closed and reopening when clicked
|
||||
- The macOS DMG install window now has custom Cline artwork and layout
|
||||
- Credentials embedded in git remote URLs are now redacted from the workspace information sent to the model
|
||||
|
||||
## 0.0.17
|
||||
|
||||
- Plugins, MCP, Skills, Rules, Hooks, and Tools are now one Customize hub with tabbed sections and live counts. Catalog-backed tabs show what you have installed followed by an inline Browse section, so installing something from the catalog immediately appears above — the separate Marketplace page is gone
|
||||
- Redesigned the Models page: providers are grouped into Connected, Popular, and All with their auth kind and configuration status instead of per-row toggles. OAuth providers now offer a browser sign-in rather than an API key field, with a collapsed manual-key escape hatch where supported, and explicit Connect / Disconnect / Sign out actions
|
||||
- Voice input moved to its own Settings → Voice page that only offers connected transcription-capable providers and preselects a default model. The composer's microphone button now appears only once a voice model is configured
|
||||
- Sidebar sessions are always grouped by project, with pinned sessions leading each group and scheduled sessions marked by an inline clock. The Favorite action is now called Pin
|
||||
- New, Schedule, and Customize each got their own labeled row below the logo. New starts a fresh task and puts your cursor straight in the composer
|
||||
- Session search moved into a dialog behind the search icon in the logo row, and it now searches your full history instead of only the sessions already loaded in the sidebar
|
||||
- Added suggested schedule templates to the Schedule page
|
||||
- Add Provider opens a dialog instead of swapping out the page
|
||||
- Desktop notifications are now a single section under General, so the Event/Notify/Sound matrix no longer reads as a peer of settings like Dark mode
|
||||
- The agent's todo tool and the Agenda panel have been removed; scheduled tasks are unaffected
|
||||
- Fixed the provider list being unscrollable while a provider detail panel was open
|
||||
- Fixed a failed settings save leaving the Models page claiming a provider configuration that was never written to disk
|
||||
- Fixed Uninstall buttons collapsing to a broken square next to Install
|
||||
- Fixed unreadable selected text inside input fields
|
||||
- New files are now created with your platform's native line endings
|
||||
- Fixed the codebase search tool crashing the app on files containing a single enormous line
|
||||
- The hub's event log can no longer grow until it fills your disk
|
||||
|
||||
## 0.0.16
|
||||
|
||||
- The agent can now be handed off between Hub instances without losing work: a Hub that is restarting refuses new work while it finishes what it is running, and the app replays anything it missed while disconnected instead of dropping it
|
||||
- Fixed tool calling being silently disabled for custom OpenAI-Compatible models whose capability list was inferred from convenience flags like `supportsReasoning`
|
||||
- Refreshed the model catalog, which updates model lists and pricing across providers and changes the resolved default model for several of them (DeepSeek, Crof, CrossModel, Eden AI, Kilo, and NanoGPT)
|
||||
- The app now honors server-side feature flags, refreshing them when your account changes
|
||||
|
||||
## 0.0.15
|
||||
|
||||
- The app is now called Cline, renamed from Cline Code. Your settings, sessions, and credentials carry over untouched — only the name and icon change
|
||||
- Refreshed app icons and branding
|
||||
- Reskinned the first-run onboarding, with an interactive welcome graphic
|
||||
- Plugins, MCP servers, and Skills are now one Plugins hub with a dedicated Marketplace page
|
||||
- The composer's model selector now leads with Recommended and Free tiers (Subscribed and Free on ClinePass), labeled by display name with descriptions, instead of an alphabetized list of raw model ids. Provider settings show the same badges and descriptions
|
||||
- Agents can now create and manage durable todos and one-time or recurring schedules
|
||||
- Fixed checkpoint restore wedging permanently. Sessions that were never prompted — and persistence-only updates — reported a bogus "running" status, so anything gated on an active turn stayed blocked forever
|
||||
- Fixed "No sessions found" flashing while session history was still loading
|
||||
- Fixed the work summary undercounting elapsed time when thinking before a tool call attached to the answer instead of the run
|
||||
- Fixed the settings gear keeping its hover state while the Account screen is open
|
||||
- Fixed ClinePass not being recognized as OAuth-managed in the chat credential gate, which asked for credentials it already had
|
||||
- Fixed copying a user message bringing along its internal envelope
|
||||
- Fixed multi-line code blocks collapsing onto a single line
|
||||
- Image, voice, and other non-chat models are no longer offered in chat model pickers
|
||||
- Fixed `PreToolUse` hook `contextModification` never reaching the model, and `PostToolUse` hook output and `cancel` control being discarded
|
||||
- Fixed provider-executed tool activity — every tool the Claude Code provider runs inside its own session — being dropped instead of shown
|
||||
- PowerShell commands now fail fast on the first error instead of flooding output and still reporting success
|
||||
- Usage now displays the billed gateway cost
|
||||
- Refreshed the model catalog, which adds AMD, Arcee, Echo, Jalapeno, Kosmik, LLM Gateway, RunInfra, and SCNet as providers and updates model lists, pricing, and per-provider default models across the board
|
||||
|
||||
## 0.0.14
|
||||
|
||||
- The app now posts native macOS notifications when a task finishes or needs your input, so you can leave Cline working in the background. Configure them under Settings → Notifications.
|
||||
- Voice input: dictate into the composer with the microphone button and your speech is transcribed as you talk, using the provider and model you have configured.
|
||||
- Commands stream their output into the transcript as they run instead of appearing all at once when the command exits. Output keeps its terminal colors, is scrollable without being yanked back to the bottom, and a long-running command can be sent to the background with "Proceed while running" so the agent moves on while it finishes.
|
||||
- Models that support image generation can now produce images during a task, and they render inline in the transcript.
|
||||
- Finished agent runs collapse into a single "Worked for 4m 12s and made 14 tool calls" summary you can expand, so the final answer stays in view instead of being buried under the working rows.
|
||||
- Reasoning traces and tool rows now open and close with an animation instead of snapping, and respect your reduced-motion setting.
|
||||
- Redesigned the question card the agent shows when it needs a decision: options are selected explicitly and submitted with a button, multiple-choice questions are supported, and there are arrow-key and A–Z shortcuts. Internal request IDs, iteration counts, and timestamps no longer appear on the card.
|
||||
- The Web search toggle in Settings now explains that only providers with built-in web search honor it, and shows which of your connected providers are ready to use it — or warns you, with a link to Models, when none of them are.
|
||||
- Refreshed assistant markdown — chat-scaled headings, quieter code blocks with a hover copy button, and table cards — now rendered through the same pipeline as the rest of Cline, so the desktop app and the cloud dashboard finally look alike.
|
||||
- Message hover actions float over the transcript instead of reserving blank space under every message, so conversations pack more tightly.
|
||||
- Restyled session hover cards: they open immediately, drop the duplicated ID and updated time, and no longer animate as you move down the list.
|
||||
- There is now a separate "Cline Code Beta" app that installs side by side with this one and tracks the experimental branch. It identifies itself as beta in the sidebar, Settings, window title, and tray, so you always know which build you are in.
|
||||
- Fixed turns that settle through the event stream — queued prompts, and the first prompt of a fresh session — staying stuck on the streaming shimmer with no final output, healing only when you sent another message. The transcript now reconciles against the saved history as soon as the turn ends.
|
||||
- Fixed sessions being given the Yolo-mode system prompt whenever auto-approve was on, even though the runtime was started in Act mode. Auto-approval is now an independent tool policy and no longer changes the advertised mode.
|
||||
- `/skill` and `/workflow` commands no longer dump the whole skill body into the chat as your message. Your typed command stays as typed, the model loads the instructions through the skills tool, and sessions are no longer titled with the first line of a skill's markdown. Sessions saved before this fix render compactly too.
|
||||
- Fixed command execution breaking for an entire session when a model emitted a full command line with no separate arguments — anything containing a space failed with `ENOENT`.
|
||||
- Restoring a checkpoint now trims the saved transcript too, so the chat no longer keeps showing turns whose file changes were just reverted.
|
||||
- Gemini custom base URLs work again, including host-root values saved before the SDK migration and proxy roots like `http://localhost:4000/gemini`, which were silently missing the API version segment and 404ing.
|
||||
- LiteLLM input token limits reported by the server are preserved instead of being replaced with a 128K default.
|
||||
- Fixed misaligned columns in the Usage table, and added a See More link to the full usage dashboard.
|
||||
- Fixed routine dialog dropdowns not responding to mouse clicks.
|
||||
- First beta release. Cline Code Beta installs side by side with the stable app so you can compare the two, and updates automatically from its own beta channel — stable installs are unaffected.
|
||||
- Cloud sessions (preview): run sessions in Cline's cloud straight from the desktop app. Connect GitHub during onboarding, pick a repository and branch, and hand sessions off between devices — transcripts, approvals, and queued prompts stay in sync, and you can rename cloud sessions and switch models mid-session. Turn it on with the Cloud sessions toggle in Settings.
|
||||
- Avatar overlay (preview): a floating desktop companion that reacts to what your sessions are doing.
|
||||
- Onboarding now includes a GitHub integration step.
|
||||
- Early proof of concept for running sessions in SSH remote environments.
|
||||
- Includes everything from the upcoming stable release: microphone voice input in the composer, model-driven image generation, redesigned question prompts, animated reasoning and tool disclosures, and session list polish.
|
||||
|
||||
## 0.0.13
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Desktop Experimental Branch & Beta Channel
|
||||
|
||||
How experimental desktop features are developed on the `desktop-experimental`
|
||||
branch, shipped to users as **Cline Beta**, and graduated into `main`.
|
||||
branch, shipped to users as **Cline Code Beta**, and graduated into `main`.
|
||||
The release mechanics (workflow internals, secrets) live in
|
||||
[`.github/workflows/desktop-publish.yml`](../../../.github/workflows/desktop-publish.yml)
|
||||
and the `publish-desktop` skill
|
||||
@@ -12,8 +12,8 @@ this doc is the process.
|
||||
|
||||
The beta is a **separate app**, not a mode of the stable app:
|
||||
|
||||
- Product name `Cline Beta`, bundle identifier `bot.cline.app.beta`
|
||||
(stable is `Cline` / `bot.cline.app`) — set by
|
||||
- Product name `Cline Code Beta`, bundle identifier `bot.cline.app.beta`
|
||||
(stable is `Cline Code` / `bot.cline.app`) — set by
|
||||
[`src-tauri/tauri.beta.conf.json`](./src-tauri/tauri.beta.conf.json), which
|
||||
is layered over `tauri.release.conf.json` at build time.
|
||||
- Both apps install and run **side by side**, so people can compare beta
|
||||
|
||||
@@ -6,9 +6,8 @@ Tauri desktop shell + Bun sidecar backend + Next.js UI for running and inspectin
|
||||
|
||||
From `apps/examples/desktop-app/`:
|
||||
|
||||
- `bun run dev:headless` - Next.js UI (`http://localhost:3125`) and sidecar backend with a fresh shared approval credential
|
||||
- `bun run dev:web` - Next.js UI only (approval-gated tools require `dev:headless` or the native app)
|
||||
- `bun run dev:sidecar` - sidecar backend only (approval-gated tools require `dev:headless` or the native app)
|
||||
- `bun run dev:web` - Next.js UI only (`http://localhost:3125`)
|
||||
- `bun run dev:sidecar` - sidecar backend only
|
||||
- `bun run dev` - Tauri desktop dev
|
||||
- `bun run build` - build web assets
|
||||
- `bun run build:sidecar` - build the Bun sidecar bundle
|
||||
@@ -17,38 +16,6 @@ From `apps/examples/desktop-app/`:
|
||||
- `bun run package:desktop` - package the current OS desktop app into `dist/desktop/`
|
||||
- `bun run typecheck` - TypeScript check
|
||||
|
||||
## Customizing the macOS Install Window
|
||||
|
||||
The drag-to-Applications window is configured by `bundle.macOS.dmg` in
|
||||
[`src-tauri/tauri.conf.json`](./src-tauri/tauri.conf.json). Its artwork comes
|
||||
from the PNG sources in [`src-tauri/dmg/`](./src-tauri/dmg/); the
|
||||
`background.gen.tiff` Finder actually renders is a gitignored build artifact
|
||||
regenerated from them on every build.
|
||||
|
||||
1. The current source artwork is `640x400`. Export `background.png` at 1x and
|
||||
`background@2x.png` at 2x.
|
||||
2. Currently the app icons are centered at `(140, 200)` and
|
||||
the Applications folder centered at `(500, 200)`. If updating artwork, update `appPosition`
|
||||
and `applicationFolderPosition` to reposition the app icons.
|
||||
3. Build with `bun run build:binary`. Before compiling, the build validates
|
||||
both PNG dimensions, combines them with `tiffutil` into the Retina-aware
|
||||
`src-tauri/dmg/background.gen.tiff`, and verifies the TIFF contains the
|
||||
expected 1x and 2x representations. Run `bun run dmg:background` to do just
|
||||
that step, e.g. to sanity-check new artwork without a full build. The DMG
|
||||
is written beneath `src-tauri/target/release/bundle/dmg/`.
|
||||
|
||||
Run `bun run test:dmg-background` for the cross-platform checks covering the
|
||||
committed PNG dimensions and TIFF validation logic.
|
||||
|
||||
The configured `640x432` Finder window is intentionally 32 points taller than
|
||||
the `640x400` background. That extra height matches the Finder chrome in the
|
||||
currently verified packaged layout; re-check it after material macOS or Finder
|
||||
changes. The project deliberately uses a multi-resolution TIFF even though
|
||||
Tauri's documented background formats are PNG, JPG, and GIF: Finder renders
|
||||
both the 1x and 2x representations from a single background file. Re-check the
|
||||
packaged DMG after upgrading Tauri in case its background validation changes.
|
||||
|
||||
|
||||
## Login Shell PATH Resolution
|
||||
|
||||
Apps launched from Finder/the Dock inherit launchd's minimal `PATH`
|
||||
@@ -63,6 +30,59 @@ API keys, `JAVA_HOME`-style tool roots) are not pulled in. Set
|
||||
`CLINE_SIDECAR_SKIP_SHELL_PATH=1` to disable. Implementation and details:
|
||||
[`sidecar/shell-path.ts`](./sidecar/shell-path.ts).
|
||||
|
||||
## SSH Remote Environments (v0)
|
||||
|
||||
Open **Settings → Remote** to add and test an SSH host. Saving or testing a
|
||||
profile does not activate it. From the welcome chat, open the environment
|
||||
selector beside the workspace picker and choose the saved host; that selection
|
||||
starts the SSH connection at the remote user's home directory. Choose **Add
|
||||
project…** from the normal workspace selector to browse that machine and select
|
||||
a project, or choose **Local** in the environment selector to disconnect. Recent
|
||||
and last-used workspaces are remembered separately for each SSH host and for
|
||||
the local machine.
|
||||
|
||||
SSH config aliases are supported. Leave **Port** blank to use the alias's SSH
|
||||
configuration (including its configured port), or enter a port to override it.
|
||||
The desktop keeps its webview and native integration local; only the
|
||||
authenticated Cline Hub protocol is forwarded through SSH. Agent tools,
|
||||
workspace discovery, Git metadata, and session persistence therefore run on the
|
||||
SSH host, while approvals and live session events return to the desktop.
|
||||
|
||||
The desktop stores host metadata at
|
||||
`~/.cline/data/settings/remote-environments.json` with mode `0600`. It stores an
|
||||
identity-file path, never private-key contents. On first connect it uploads a
|
||||
content-addressed, branch-matched, self-contained Hub helper under
|
||||
`~/.cline/code/remote/`, binds the Hub to remote loopback, and forwards it to a
|
||||
random local loopback port. Linux x64 and arm64 helpers are bundled by
|
||||
`bun run build:sidecar:bin`; 32-bit Raspberry Pi operating systems are not
|
||||
supported in v0. The current helper is roughly 110 MB because it includes its
|
||||
own runtime. It is copied once per matching desktop build and cached, with no
|
||||
`apt`, `npm`, root access,
|
||||
global CLI install, or public Hub port. Disconnecting stops the desktop-owned
|
||||
remote Hub but leaves the helper cached for a faster reconnect. The helper
|
||||
imports the remote login-shell `PATH`, so user-installed Git, GitHub CLI, and
|
||||
MCP executables remain visible.
|
||||
|
||||
The desktop uses its own discovery record, so an existing Cline CLI/Hub on the
|
||||
same account is neither replaced nor stopped. Both Hub processes can coexist
|
||||
while the desktop is connected; this isolation is intentional for the proof of
|
||||
concept so a branch-matched desktop helper cannot disrupt another Cline build.
|
||||
|
||||
v0 intentionally leaves file attachments and opening a remote file in a local
|
||||
editor disabled. Text, images, file mentions/search, Git branch operations,
|
||||
session history, and remote agent tools are supported. The current desktop
|
||||
provider access/API token is sent through the authenticated tunnel for the
|
||||
session; reusable OAuth refresh credentials are not copied into remote provider
|
||||
settings.
|
||||
|
||||
For a real SSH acceptance run, `scripts/verify-ssh-poc.ts` accepts
|
||||
`CLINE_SSH_TEST_HOST`, `CLINE_SSH_TEST_USER`, `CLINE_SSH_TEST_KEY`,
|
||||
`CLINE_SSH_TEST_WORKSPACE`, and `CLINE_SSH_TEST_HELPER`. It starts a remote
|
||||
connection at the SSH user's home, starts an agent session in the test
|
||||
workspace with the selected desktop provider, asks the agent to read
|
||||
`REMOTE_MARKER.txt`, then verifies the session appears in remote history and
|
||||
that its messages can be read back.
|
||||
|
||||
## Web Visual System
|
||||
|
||||
The framework-neutral color, typography, radius, and navigation contract lives
|
||||
@@ -88,7 +108,7 @@ lost: the `desktop-latest` release/tag (its feed URL is baked into shipped
|
||||
apps) and the updater private key (`TAURI_SIGNING_PRIVATE_KEY` — without it,
|
||||
shipped apps can't verify new updates).
|
||||
|
||||
There is also a beta channel ("Cline Beta", a separate app that installs
|
||||
There is also a beta channel ("Cline Code Beta", a separate app that installs
|
||||
side by side with stable) cut from the `desktop-experimental` branch and
|
||||
served by the rolling `desktop-beta` release — the same never-delete rule
|
||||
applies to it. The experimental-branch process and beta release flow live in
|
||||
@@ -174,6 +194,16 @@ Desktop transport envelope:
|
||||
## Data + Storage
|
||||
|
||||
- Session artifacts are written under `~/.cline/data/sessions/<sessionId>/` (or `CLINE_SESSION_DATA_DIR`).
|
||||
- Desktop avatar packages live under `~/.cline/avatars/<avatar-name>/`. Each package
|
||||
contains a v2 `spritesheet.webp` (or PNG) and either an `avatar.json` or
|
||||
`pet.json` manifest with
|
||||
`id`, `displayName`, `description`, `spriteVersionNumber: 2`, and
|
||||
`spritesheetPath`. If both manifests exist, `avatar.json` takes precedence.
|
||||
The bundled Cline Bot avatar is selected and enabled by default, with Mom also
|
||||
available as a bundled option.
|
||||
Visibility and the selected installed avatar are configured independently under
|
||||
**Settings → General → Desktop avatar**; both values are stored in
|
||||
`~/.cline/avatars/selected.json`.
|
||||
- Canonical replay/export artifact: `<sessionId>.messages.json`.
|
||||
- `<sessionId>.messages.json` is expected to contain ordered messages plus assistant `modelInfo` and `metrics` (including cache token fields when provided by the model runtime).
|
||||
- `<sessionId>.hooks.jsonl` is observability/debug telemetry and should not be required for normal history replay/export flows.
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
{
|
||||
"name": "@cline/code",
|
||||
"version": "0.0.21",
|
||||
"version": "0.0.14-beta.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:ui": "bun -F @cline/ui build",
|
||||
"predev:web": "bun run build:ui",
|
||||
"dev:web": "next dev webview -p 3125 --turbo",
|
||||
"dev:sidecar": "bun run sidecar/index.ts",
|
||||
"predev:headless": "bun run build:ui",
|
||||
"dev:headless": "bun run scripts/dev-headless.ts",
|
||||
"dev": "tauri dev --config src-tauri/tauri.dev.conf.json",
|
||||
"dev": "tauri dev",
|
||||
"prebuild": "bun run build:ui",
|
||||
"build": "bun run bun.mts",
|
||||
"build:sidecar": "mkdir -p dist/sidecar && bun build ./sidecar/index.ts --outfile ./dist/sidecar/index.js --target bun",
|
||||
"build:sidecar:bin": "bun run scripts/build-sidecar-bin.ts",
|
||||
"build:binary": "tauri build",
|
||||
"dmg:background": "bun run scripts/dmg-background.ts",
|
||||
"test:dmg-background": "bun test scripts/dmg-background.test.ts",
|
||||
"package": "bun run package:desktop",
|
||||
"package:desktop": "bun run scripts/package-desktop.ts",
|
||||
"package:desktop:mac": "bun run scripts/package-desktop.ts --platform mac",
|
||||
@@ -30,15 +26,19 @@
|
||||
"clean": "rm -rf webview/.next webview/out node_modules dist && (cd src-tauri && rm -rf target node_modules dist)"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/gateway": "4.0.31",
|
||||
"@ai-sdk/google": "4.0.44",
|
||||
"@ai-sdk/openai": "4.0.41",
|
||||
"@ai-sdk/react": "4.0.44",
|
||||
"@base-ui/react": "^1.2.0",
|
||||
"@cline/core": "workspace:*",
|
||||
"@cline/llms": "workspace:*",
|
||||
"@cline/shared": "workspace:*",
|
||||
"@cline/ui": "workspace:*",
|
||||
"@pierre/diffs": "^1.3.0",
|
||||
"@fontsource-variable/geist-mono": "^5.2.8",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@pierre/diffs": "^1.3.0",
|
||||
"@radix-ui/react-accordion": "1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "1.1.15",
|
||||
"@radix-ui/react-aspect-ratio": "1.1.8",
|
||||
@@ -70,9 +70,8 @@
|
||||
"@shikijs/themes": "^4.2.0",
|
||||
"@streamdown/cjk": "^1.0.3",
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-notification": "^2.0.0",
|
||||
"ai": "^7.0.58",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"ansi-to-react": "^6.2.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
@@ -82,7 +81,6 @@
|
||||
"next": "16.2.11",
|
||||
"next-themes": "^0.4.6",
|
||||
"pino": "^10.3.1",
|
||||
"posthog-node": "^5.8.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.4",
|
||||
"react-day-picker": "9.13.2",
|
||||
@@ -94,7 +92,7 @@
|
||||
"streamdown": "^2.5.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^3.24.1"
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
|
||||
@@ -38,22 +38,54 @@ const sidecarOutfile = (targetTriple: string): string => {
|
||||
return `./src-tauri/bin/code-sidecar-${targetTriple}${extension}`;
|
||||
};
|
||||
|
||||
const buildSidecar = async (targetTriple: string): Promise<string> => {
|
||||
const outfile = sidecarOutfile(targetTriple);
|
||||
const buildSidecar = async (
|
||||
targetTriple: string,
|
||||
outfile = sidecarOutfile(targetTriple),
|
||||
entrypoint = "./sidecar/index.ts",
|
||||
minify = false,
|
||||
): Promise<string> => {
|
||||
const bunTarget = resolveBunCompileTarget(targetTriple);
|
||||
// Telemetry config must be inlined into the compiled binary: a packaged
|
||||
// app launched from Finder/the Dock has no OTEL_* env at runtime, so
|
||||
// without this the sidecar silently ships with telemetry disabled.
|
||||
// Verify with `<binary> --telemetry-selfcheck` after building.
|
||||
const defines = telemetryDefineArgs();
|
||||
const optimizationArgs = minify ? ["--minify"] : [];
|
||||
// A compiled Bun executable otherwise reads .env and bunfig.toml from its
|
||||
// launch directory before our entrypoint runs. Remote helpers are launched
|
||||
// from an SSH user's home directory, so that behavior can both make the
|
||||
// helper fail on an unrelated dotenv file and leak workspace credentials
|
||||
// into the Hub process. Packaged binaries must depend only on their explicit
|
||||
// process environment and compiled configuration.
|
||||
const runtimeIsolationArgs = [
|
||||
"--no-compile-autoload-dotenv",
|
||||
"--no-compile-autoload-bunfig",
|
||||
];
|
||||
if (bunTarget) {
|
||||
await $`bun build ./sidecar/index.ts --compile --target=${bunTarget} ${defines} --outfile ${outfile}`;
|
||||
await $`bun build ${entrypoint} --compile --target=${bunTarget} ${runtimeIsolationArgs} ${optimizationArgs} ${defines} --outfile ${outfile}`;
|
||||
} else {
|
||||
await $`bun build ./sidecar/index.ts --compile ${defines} --outfile ${outfile}`;
|
||||
await $`bun build ${entrypoint} --compile ${runtimeIsolationArgs} ${optimizationArgs} ${defines} --outfile ${outfile}`;
|
||||
}
|
||||
return outfile;
|
||||
};
|
||||
|
||||
// SSH environments run the same Hub build as the desktop in a dedicated
|
||||
// bootstrap/daemon binary. It intentionally excludes the desktop HTTP server,
|
||||
// command router, and UI backend. Linux x64 and arm64 cover common SSH hosts.
|
||||
const buildRemoteHelpers = async (): Promise<void> => {
|
||||
for (const targetTriple of [
|
||||
"x86_64-unknown-linux-gnu",
|
||||
"aarch64-unknown-linux-gnu",
|
||||
]) {
|
||||
await buildSidecar(
|
||||
targetTriple,
|
||||
`./src-tauri/bin/remote-helpers/code-sidecar-${targetTriple}`,
|
||||
"./sidecar/remote-helper.ts",
|
||||
true,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Tauri's universal-apple-darwin pseudo-target lipos the Rust binary itself
|
||||
// but expects sidecars (externalBin) to already be fat binaries named
|
||||
// `<name>-universal-apple-darwin`, so build both slices and merge them here.
|
||||
@@ -68,12 +100,13 @@ const buildUniversalMacSidecar = async (): Promise<void> => {
|
||||
|
||||
const main = async () => {
|
||||
const targetTriple = await resolveTargetTriple();
|
||||
await $`mkdir -p src-tauri/bin`;
|
||||
await $`mkdir -p src-tauri/bin src-tauri/bin/remote-helpers`;
|
||||
if (targetTriple === "universal-apple-darwin") {
|
||||
await buildUniversalMacSidecar();
|
||||
return;
|
||||
} else {
|
||||
await buildSidecar(targetTriple);
|
||||
}
|
||||
await buildSidecar(targetTriple);
|
||||
await buildRemoteHelpers();
|
||||
};
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user