mirror of
https://github.com/cline/cline.git
synced 2026-09-12 09:14:50 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98c44a2825 | ||
|
|
b99c077671 | ||
|
|
2dac0ea1f8 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix showing the ai core exisiting models when resource group field is empty (using the default resource group)
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Add orchestrator script and improve standalone service for local testing
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Reset account view on sign out
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix issue on Account view where balance is fetched twice that cause janky UI
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fixes an issue where thinking text from litellm was not being passed through to Cline thinking UI
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Add gRPC recorder request filtering logic to make it more generic and configurable
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix Ollama connection issue to default endpoint at port 11434
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Optimized Cline for GPT-5 model family with an aligned system prompt
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
REfactoring Tool Executor
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Add search functionality to API provider dropdown
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Add optional middleware for recording gRPC calls in the view.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Adding telemetry for terminal command issue
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Remove disabled approve / reject buttons from UI.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Sync taskHistory across Cline instances so that it will update for all Cline instances when one instance creates, adds to, or deletes a task.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Adding a grpc recording class with its builder
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fixed minor bug where entering slash commands would result in the following word being removed from the chat text area
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Added checkpoints warning when users start a multiroot task
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix issue where editor panel gets reset to chat view on each chat input focus event
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Exclude dependency folders for deep-planning prompt
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Sambanova provider: add DeepSeek-V3.1
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Add "Use custom prompt" option to Ollama provider
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
fetch deployment id in design-time instead of runtime
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
add free grok-coder-free model to cline provider
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Adding a new standalone core api server script for local and integration testing purposes
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
correctly passes extended thinking tokens when max output is set to -1 default, unset temp for anthropic models when extended thinking is on
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Added multiroot support for file mentions
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Added markdown support to focus chain text, allowing the model to display more interesting focus chains
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix AutoApproveModal overflowing issue
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Added scripts to generate providers.go
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Setup Raw Structure for implementing multi-workspace support with WorkspaceRoot
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Adds a CLI testing framework for the Cline Core extension, providing gRPC-based integration clients and utilities for automated scenarios
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Dify.ai api integration
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
support orchestration mode for sap provider
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Improve Gemini Rate Limit handling
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: Support Anthropic Caching when using LiteLLM
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Refactored the Telemetry service to support multiple providers for a future where we support Otel
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Reasoning Effort Minimal on OpenAI
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Adding basic multi root algorithm
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Adding hooks to record getlateststate for testing
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Prompt changes for deep-planning in windows/powershell
|
||||
@@ -1,48 +0,0 @@
|
||||
# Cline Development Environment Variables
|
||||
# Copy this file to .env and fill in your actual values
|
||||
# Values should be obtained from 1Password shared vault for development
|
||||
|
||||
# ============================================================================
|
||||
# DEVELOPMENT FLAGS
|
||||
# Recomend not changing these unless you know what you're doing they are set by the launch.json normally
|
||||
# ============================================================================
|
||||
# IS_DEV=true
|
||||
# CLINE_ENVIRONMENT=local
|
||||
|
||||
# ============================================================================
|
||||
# POSTHOG TELEMETRY (Existing)
|
||||
# ============================================================================
|
||||
# Get these values from 1Password shared vault
|
||||
TELEMETRY_SERVICE_API_KEY=your-posthog-telemetry-api-key
|
||||
ERROR_SERVICE_API_KEY=your-posthog-error-tracking-api-key
|
||||
|
||||
# ============================================================================
|
||||
# TELEMETRY PROVIDER CONTROL
|
||||
# ============================================================================
|
||||
# Control which telemetry providers are active
|
||||
POSTHOG_TELEMETRY_ENABLED=true # Enable PostHog telemetry (default: true)
|
||||
# Set to false to disable Telemetry completely
|
||||
|
||||
# ============================================================================
|
||||
# OPTIONAL DEVELOPMENT SETTINGS
|
||||
# ============================================================================
|
||||
# Uncomment and modify as needed for development
|
||||
|
||||
# Multi-root workspace debugging
|
||||
# MULTI_ROOT_TRACE=true
|
||||
|
||||
# gRPC recorder for testing
|
||||
# GRPC_RECORDER_ENABLED=true
|
||||
# GRPC_RECORDER_FILE_NAME=test-recording
|
||||
|
||||
# Test mode
|
||||
# E2E_TEST=true
|
||||
# IS_TEST=true
|
||||
|
||||
# ============================================================================
|
||||
# USAGE INSTRUCTIONS
|
||||
# ============================================================================
|
||||
# 1. Copy this file: cp .env.example .env
|
||||
# 2. Get PostHog keys from 1Password shared vault
|
||||
# 3. Update the values in .env
|
||||
# 4. The .env file is gitignored for security
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
/docs/
|
||||
/.github/ @saoudrizwan @garoth @sjf
|
||||
/README.md @saoudrizwan @nickbaumann98
|
||||
/.github/ @saoudrizwan @dcbartlett
|
||||
/README.md @saoudrizwan @nickbaumann98
|
||||
@@ -5,7 +5,7 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**Important:** All bug reports must be reproducible using Claude Sonnet 4.5. Cline uses complex prompts so less capable models may not work as expected.
|
||||
**Important:** All bug reports must be reproducible using Claude Sonnet 4. Cline uses complex prompts so less capable models may not work as expected.
|
||||
- type: dropdown
|
||||
id: plugin-type
|
||||
attributes:
|
||||
@@ -49,7 +49,7 @@ body:
|
||||
attributes:
|
||||
label: Provider/Model
|
||||
description: What provider and model were you using when the issue occurred?
|
||||
placeholder: 'e.g., cline:anthropic/claude-sonnet-4.5, gemini:gemini-2.5-pro-exp-03-25'
|
||||
placeholder: 'e.g., cline:anthropic/claude-sonnet-4, gemini:gemini-2.5-pro-exp-03-25'
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
|
||||
@@ -69,7 +69,5 @@ jobs:
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
OVSX_PAT: ${{ secrets.OVSX_PAT }}
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
CLINE_ENVIRONMENT: production
|
||||
run: npm run publish:marketplace:nightly
|
||||
@@ -95,8 +95,6 @@ jobs:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
OVSX_PAT: ${{ secrets.OVSX_PAT }}
|
||||
CLINE_ENVIRONMENT: production
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
run: |
|
||||
# Required to generate the .vsix
|
||||
vsce package --allow-package-secrets sendgrid --out "cline-${{ steps.get_version.outputs.version }}.vsix"
|
||||
|
||||
+106
-139
@@ -1,9 +1,6 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches:
|
||||
@@ -17,45 +14,7 @@ permissions:
|
||||
pull-requests: write # Needed to add comments/annotations to PRs
|
||||
|
||||
jobs:
|
||||
quality-checks:
|
||||
runs-on: ubuntu-latest
|
||||
name: Quality Checks
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Cache root dependencies
|
||||
uses: actions/cache@v4
|
||||
id: root-cache
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- name: Cache webview-ui dependencies
|
||||
uses: actions/cache@v4
|
||||
id: webview-cache
|
||||
with:
|
||||
path: webview-ui/node_modules
|
||||
key: ${{ runner.os }}-npm-webview-${{ hashFiles('webview-ui/package-lock.json') }}
|
||||
|
||||
- name: Install root dependencies
|
||||
if: steps.root-cache.outputs.cache-hit != 'true'
|
||||
run: npm ci
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
if: steps.webview-cache.outputs.cache-hit != 'true'
|
||||
run: cd webview-ui && npm ci
|
||||
|
||||
- name: Run Quality Checks (Parallel)
|
||||
run: npm run ci:check-all
|
||||
|
||||
test:
|
||||
needs: quality-checks
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -74,6 +33,18 @@ jobs:
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
# Setup Python for coverage script
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install requests
|
||||
|
||||
# Cache root dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache root dependencies
|
||||
uses: actions/cache@v4
|
||||
id: root-cache
|
||||
@@ -81,6 +52,7 @@ jobs:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
# Cache webview-ui dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache webview-ui dependencies
|
||||
uses: actions/cache@v4
|
||||
id: webview-cache
|
||||
@@ -96,45 +68,56 @@ jobs:
|
||||
if: steps.webview-cache.outputs.cache-hit != 'true'
|
||||
run: cd webview-ui && npm ci
|
||||
|
||||
- name: Install xvfb on Linux
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb
|
||||
|
||||
- name: Set up NPM on Windows
|
||||
if: runner.os == 'Windows'
|
||||
run: |
|
||||
npm config set script-shell "C:\\Program Files\\Git\\bin\\bash.exe"
|
||||
|
||||
# Build the extension and tests (without redundant checks)
|
||||
- name: Type Check
|
||||
run: npm run check-types
|
||||
|
||||
- name: Lint Check
|
||||
run: npm run lint
|
||||
|
||||
- name: Format Check
|
||||
run: npm run format
|
||||
|
||||
# Build the extension before running tests
|
||||
- name: Build Tests and Extension
|
||||
id: build_step
|
||||
run: npm run ci:build
|
||||
run: npm run pretest
|
||||
|
||||
- name: Unit Tests with coverage - Linux
|
||||
id: unit_tests_linux
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os == 'Linux' }}
|
||||
- name: Unit Tests
|
||||
run: |
|
||||
npx nyc --nycrc-path .nycrc.unit.json --reporter=lcov npm run test:unit
|
||||
npm install --no-save nyc
|
||||
npx nyc --nycrc-path .nycrc.unit.json --reporter=lcov npm run test:unit
|
||||
|
||||
- name: Unit Tests - Non-Linux
|
||||
id: unit_tests_non_linux
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os != 'Linux' }}
|
||||
# Run extension tests with coverage
|
||||
- name: Extension Integration Tests with Coverage
|
||||
id: extension_coverage
|
||||
continue-on-error: true
|
||||
run: |
|
||||
npm run test:unit
|
||||
|
||||
- name: Extension Integration Tests - Linux
|
||||
id: integration_tests_linux
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os == 'Linux' }}
|
||||
run: xvfb-run -a npm run test:coverage
|
||||
|
||||
- name: Extension Integration Tests - Non-Linux
|
||||
id: integration_tests_non_linux
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os != 'Linux' }}
|
||||
run: npm run test:integration
|
||||
node ./scripts/test-ci.js 2>&1 | tee extension_coverage.txt
|
||||
# Default the encoding to UTF-8 - It's not the default on Windows
|
||||
PYTHONUTF8=1 PYTHONPATH=.github/scripts python -m coverage_check extract-coverage extension_coverage.txt --type=extension --github-output --verbose
|
||||
|
||||
# Run webview tests with coverage
|
||||
- name: Webview Tests with Coverage
|
||||
id: webview_tests
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
id: webview_coverage
|
||||
continue-on-error: true
|
||||
run: |
|
||||
cd webview-ui
|
||||
npm run test:coverage
|
||||
# Ensure coverage dependency is installed
|
||||
npm install --no-save @vitest/coverage-v8
|
||||
npm run test:coverage 2>&1 | tee webview_coverage.txt
|
||||
cd ..
|
||||
# Default the encoding to UTF-8 - It's not the default on Windows
|
||||
PYTHONUTF8=1 PYTHONPATH=.github/scripts python -m coverage_check extract-coverage webview-ui/webview_coverage.txt --type=webview --github-output --verbose
|
||||
|
||||
# Save coverage reports as artifacts (workflow-scoped)
|
||||
- name: Save Coverage Reports
|
||||
uses: actions/upload-artifact@v4
|
||||
# Only upload artifacts on Linux - We only need coverage from one OS
|
||||
@@ -142,21 +125,52 @@ jobs:
|
||||
with:
|
||||
name: pr-coverage-reports
|
||||
path: |
|
||||
coverage-unit/lcov.info
|
||||
webview-ui/coverage/lcov.info
|
||||
extension_coverage.txt
|
||||
webview-ui/webview_coverage.txt
|
||||
|
||||
test-platform-integration:
|
||||
needs: quality-checks
|
||||
# Set the check as failed if any of the tests failed
|
||||
- name: Check for test failures
|
||||
run: |
|
||||
# Check if any of the test steps failed
|
||||
# https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/accessing-contextual-information-about-workflow-runs#steps-context
|
||||
if [ "${{ steps.extension_coverage.outcome }}" != "success" ]; then
|
||||
echo "Extension Integration Tests failed, see previous step for test output."
|
||||
fi
|
||||
if [ "${{ steps.webview_coverage.outcome }}" != "success" ]; then
|
||||
echo "Webview Tests failed, see previous step for test output."
|
||||
fi
|
||||
if [ "${{ steps.extension_coverage.outcome }}" != "success" ] || [ "${{ steps.webview_coverage.outcome }}" != "success" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
coverage:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
# Only run on PRs to main branch
|
||||
if: github.event_name == 'pull_request' && github.base_ref == 'main'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Fetch all history for accurate comparison
|
||||
|
||||
# Setup Python for coverage script
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install requests
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
|
||||
# Cache root dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache root dependencies
|
||||
uses: actions/cache@v4
|
||||
id: root-cache
|
||||
@@ -164,6 +178,7 @@ jobs:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
# Cache webview-ui dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache webview-ui dependencies
|
||||
uses: actions/cache@v4
|
||||
id: webview-cache
|
||||
@@ -171,14 +186,6 @@ jobs:
|
||||
path: webview-ui/node_modules
|
||||
key: ${{ runner.os }}-npm-webview-${{ hashFiles('webview-ui/package-lock.json') }}
|
||||
|
||||
# Cache testing-platform dependencies
|
||||
- name: Cache testing-platform dependencies
|
||||
uses: actions/cache@v4
|
||||
id: testing-platform-cache
|
||||
with:
|
||||
path: testing-platform/node_modules
|
||||
key: ${{ runner.os }}-npm-testing-platform-${{ hashFiles('testing-platform/package-lock.json') }}
|
||||
|
||||
- name: Install root dependencies
|
||||
if: steps.root-cache.outputs.cache-hit != 'true'
|
||||
run: npm ci
|
||||
@@ -187,70 +194,30 @@ jobs:
|
||||
if: steps.webview-cache.outputs.cache-hit != 'true'
|
||||
run: cd webview-ui && npm ci
|
||||
|
||||
- name: Compile standalone
|
||||
run: npm run compile-standalone
|
||||
# Build the extension before running tests
|
||||
- name: Build Extension
|
||||
run: npm run compile
|
||||
|
||||
- name: Install testing platform dependencies
|
||||
if: steps.testing-platform-cache.outputs.cache-hit != 'true'
|
||||
run: cd testing-platform && npm ci
|
||||
|
||||
- name: Running testing platform integration spec tests
|
||||
continue-on-error: true
|
||||
timeout-minutes: 7
|
||||
# Temporarily wrapping the test command to always return a neutral exit code.
|
||||
# This prevents the job from showing as failed and avoids distracting developers
|
||||
# until the integration tests are ready to be enforced.
|
||||
run: |
|
||||
npm run test:tp-orchestrator -- tests/specs/ --count=1 --coverage || true
|
||||
|
||||
- name: Save Coverage Reports
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test-platform-integration-core-coverage
|
||||
path: coverage/**/lcov.info
|
||||
|
||||
qlty:
|
||||
needs: [test, test-platform-integration]
|
||||
runs-on: ubuntu-latest
|
||||
# Run on PRs to main, pushes to main, and manual dispatches
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download unit tests coverage reports
|
||||
# Download coverage artifacts from test job
|
||||
- name: Download Coverage Reports
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: pr-coverage-reports
|
||||
path: .
|
||||
path: . # Download to root directory to match expected paths
|
||||
|
||||
- name: Upload core unit tests coverage to Qlty
|
||||
uses: qltysh/qlty-action/coverage@v2
|
||||
with:
|
||||
token: ${{ secrets.QLTY_COVERAGE_TOKEN }}
|
||||
# we can merge multiple files if necessary
|
||||
files: |
|
||||
coverage-unit/lcov.info
|
||||
tag: unit:core
|
||||
# Process coverage workflow
|
||||
- name: Process coverage workflow
|
||||
id: coverage
|
||||
run: |
|
||||
# Extract PR number from GITHUB_REF
|
||||
PR_NUMBER=$(echo "$GITHUB_REF" | sed -e 's/refs\/pull\///' -e 's/\/merge//')
|
||||
|
||||
- name: Upload webview-ui unit tests coverage to Qlty
|
||||
uses: qltysh/qlty-action/coverage@v2
|
||||
with:
|
||||
token: ${{ secrets.QLTY_COVERAGE_TOKEN }}
|
||||
# we can merge multiple files if necessary
|
||||
files: |
|
||||
webview-ui/coverage/lcov.info
|
||||
tag: unit:webview-ui
|
||||
add-prefix: webview-ui/
|
||||
|
||||
- name: Download test platform integration core coverage artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: test-platform-integration-core-coverage
|
||||
path: integration-core-coverage-reports
|
||||
|
||||
- name: Upload core integration tests coverage to Qlty
|
||||
uses: qltysh/qlty-action/coverage@v2
|
||||
with:
|
||||
token: ${{ secrets.QLTY_COVERAGE_TOKEN }}
|
||||
files: integration-core-coverage-reports/**/lcov.info
|
||||
tag: integration:core
|
||||
# Run the coverage workflow from root directory
|
||||
PYTHONPATH=.github/scripts python -m coverage_check process-workflow \
|
||||
--base-branch ${{ github.base_ref }} \
|
||||
--pr-number $PR_NUMBER \
|
||||
--repo $GITHUB_REPOSITORY \
|
||||
--token ${{ secrets.GITHUB_TOKEN }} \
|
||||
--verbose
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
name: Trigger Jetbrains Plugin <-> Cline Tests
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
group: jetbrains-trigger-${{ github.event.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
trigger-integration-test:
|
||||
name: Run Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Generate GitHub App Token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: 1998650
|
||||
private-key: ${{ secrets.CLINE_JETBRAINS_WORKFLOW_KEY }}
|
||||
owner: cline
|
||||
repositories: intellij-plugin
|
||||
|
||||
- name: Trigger IntelliJ Plugin Integration Test
|
||||
run: |
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer ${{ steps.app-token.outputs.token }}" \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
-H "User-Agent: cline-pr-trigger" \
|
||||
-H "Content-Type: application/json" \
|
||||
https://api.github.com/repos/cline/intellij-plugin/dispatches \
|
||||
-d @- <<EOF
|
||||
{
|
||||
"event_type": "cline-pr-check",
|
||||
"client_payload": {
|
||||
"pr_number": "${{ github.event.number }}",
|
||||
"branch_name": "${{ github.head_ref }}",
|
||||
"action": "${{ github.event.action }}",
|
||||
"sha": "${{ github.event.pull_request.head.sha }}",
|
||||
"pr_title": ${{ toJSON(github.event.pull_request.title) }},
|
||||
"pr_url": "${{ github.event.pull_request.html_url }}"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
- name: Log trigger details
|
||||
run: |
|
||||
echo "Triggered IntelliJ Plugin integration test for:"
|
||||
echo " PR #${{ github.event.number }}: ${{ github.event.pull_request.title }}"
|
||||
echo " Branch: ${{ github.head_ref }}"
|
||||
echo " Action: ${{ github.event.action }}"
|
||||
echo " SHA: ${{ github.event.pull_request.head.sha }}"
|
||||
+7
-2
@@ -21,12 +21,11 @@ webview-ui/src/**/*.js.map
|
||||
# Ignore coverage directories and files
|
||||
coverage
|
||||
coverage-unit
|
||||
.nyc_output
|
||||
coverage-integration
|
||||
# But don't ignore the coverage scripts in .github/scripts/
|
||||
!.github/scripts/coverage/
|
||||
|
||||
*evals.env
|
||||
.env
|
||||
|
||||
## Generated files ##
|
||||
src/generated/
|
||||
@@ -35,3 +34,9 @@ webview-ui/src/services/grpc-client.ts
|
||||
|
||||
# E2E Tests
|
||||
test-results
|
||||
|
||||
## CLI pre-release ##
|
||||
/cli
|
||||
|
||||
.nyc_output/
|
||||
coverage-integration/
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
lint-staged
|
||||
lint-staged --no-stash
|
||||
|
||||
+6
-1
@@ -3,7 +3,12 @@
|
||||
"ts"
|
||||
],
|
||||
"spec": [
|
||||
"src/**/__tests__/*.ts"
|
||||
"src/**/__tests__/*.ts",
|
||||
"src/**/*.test.ts"
|
||||
],
|
||||
"ignore": [
|
||||
"src/test/**/*.test.ts",
|
||||
"src/test/e2e/**/*.test.ts"
|
||||
],
|
||||
"require": [
|
||||
"ts-node/register",
|
||||
|
||||
+8
-21
@@ -6,36 +6,23 @@
|
||||
"lcov"
|
||||
],
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
"src/**/*.ts",
|
||||
"src/**/*.js"
|
||||
],
|
||||
"exclude": [
|
||||
"**/*.d.ts",
|
||||
|
||||
"**/*.{test,spec}.{js,jsx,ts,tsx,mjs,cjs}",
|
||||
"**/__tests__/**",
|
||||
"**/*.test.ts",
|
||||
"**/*.test.js",
|
||||
"**/*.spec.ts",
|
||||
"**/*.spec.js",
|
||||
"**/test/**",
|
||||
"**/tests/**",
|
||||
"**/.nyc_output/**",
|
||||
"**/.vscode-test/**",
|
||||
"**/tests-results/**",
|
||||
"src/test/**",
|
||||
|
||||
"src/generated/**",
|
||||
|
||||
"**/node_modules/**",
|
||||
"**/dist/**",
|
||||
"**/out/**",
|
||||
"**/build/**",
|
||||
"**/coverage/**",
|
||||
"**/coverage-unit/**",
|
||||
"**/proto/**",
|
||||
|
||||
"**/*.{config,setup}.{js,ts,mjs,cjs}",
|
||||
"**/vite-env.d.ts",
|
||||
|
||||
"**/*.{css,scss,sass,less,styl}",
|
||||
"**/*.{svg,png,jpg,jpeg,gif,ico}",
|
||||
"**/*.{json,yaml,yml}"
|
||||
"src/test/**",
|
||||
"src/generated/**"
|
||||
],
|
||||
"extension": [
|
||||
".ts",
|
||||
|
||||
+11
-6
@@ -2,17 +2,22 @@ import { defineConfig } from "@vscode/test-cli"
|
||||
import path from "path"
|
||||
|
||||
export default defineConfig({
|
||||
files: "{out/**/*.test.js,src/**/*.test.js,!src/test/e2e/**/*.test.js,!out/src/test/e2e/**/*.test.js}",
|
||||
files: "src/test/*.test.ts",
|
||||
mocha: {
|
||||
ui: "bdd",
|
||||
timeout: 20000, // Maximum time (in ms) that a test can run before failing
|
||||
/** Set up alias path resolution during tests
|
||||
* @See {@link file://./test-setup.js}
|
||||
*/
|
||||
require: ["./test-setup.js"],
|
||||
timeout: 20000,
|
||||
require: ["ts-node/register/transpile-only", "./test-setup-integration.js"],
|
||||
},
|
||||
workspaceFolder: "test-workspace",
|
||||
version: "stable",
|
||||
extensionDevelopmentPath: path.resolve("./"),
|
||||
launchArgs: ["--disable-extensions"],
|
||||
env: {
|
||||
TS_NODE_PROJECT: "./tsconfig.test.json",
|
||||
},
|
||||
coverage: {
|
||||
reporter: ["lcov", "text"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: ["**/*.d.ts", "**/*.test.ts", "**/*.spec.ts", "**/test/**", "**/tests/**", "src/test/**", "src/generated/**"],
|
||||
},
|
||||
})
|
||||
|
||||
Vendored
+6
-10
@@ -12,14 +12,14 @@
|
||||
"args": [
|
||||
"--extensionDevelopmentPath=${workspaceFolder}",
|
||||
"--disable-workspace-trust",
|
||||
"--disable-extensions", // Avoid conflicts with installed extensions
|
||||
// Avoid conflicts with the nightly extension
|
||||
"--disable-extension=saoudrizwan.cline-nightly",
|
||||
"${workspaceFolder}"
|
||||
],
|
||||
"outFiles": [
|
||||
"${workspaceFolder}/dist/**/*.js"
|
||||
],
|
||||
"preLaunchTask": "${defaultBuildTask}",
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"env": {
|
||||
"IS_DEV": "true",
|
||||
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}",
|
||||
@@ -33,14 +33,13 @@
|
||||
"args": [
|
||||
"--extensionDevelopmentPath=${workspaceFolder}",
|
||||
"--disable-workspace-trust",
|
||||
"--disable-extensions", // Avoid conflicts with installed extensions
|
||||
"--disable-extension=saoudrizwan.cline-nightly",
|
||||
"${workspaceFolder}"
|
||||
],
|
||||
"outFiles": [
|
||||
"${workspaceFolder}/dist/**/*.js"
|
||||
],
|
||||
"preLaunchTask": "${defaultBuildTask}",
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"env": {
|
||||
"IS_DEV": "true",
|
||||
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}",
|
||||
@@ -54,14 +53,13 @@
|
||||
"args": [
|
||||
"--extensionDevelopmentPath=${workspaceFolder}",
|
||||
"--disable-workspace-trust",
|
||||
"--disable-extensions", // Avoid conflicts with installed extensions
|
||||
"--disable-extension=saoudrizwan.cline-nightly",
|
||||
"${workspaceFolder}"
|
||||
],
|
||||
"outFiles": [
|
||||
"${workspaceFolder}/dist/**/*.js"
|
||||
],
|
||||
"preLaunchTask": "${defaultBuildTask}",
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"env": {
|
||||
"IS_DEV": "true",
|
||||
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}",
|
||||
@@ -77,8 +75,9 @@
|
||||
"--user-data-dir=${workspaceFolder}/dist/tmp/user",
|
||||
"--profile-temp",
|
||||
"--sync=off",
|
||||
"--disable-extensions", // Avoid conflicts with installed extensions
|
||||
"--disable-extensions",
|
||||
"--extensionDevelopmentPath=${workspaceFolder}",
|
||||
"--disable-extension=saoudrizwan.cline-nightly",
|
||||
"${workspaceFolder}"
|
||||
],
|
||||
"outFiles": [
|
||||
@@ -87,7 +86,6 @@
|
||||
"preLaunchTask": "clean-tmp-user",
|
||||
"internalConsoleOptions": "openOnSessionStart",
|
||||
"postDebugTask": "stop",
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"env": {
|
||||
"IS_DEV": "true",
|
||||
"TEMP_PROFILE": "true",
|
||||
@@ -118,7 +116,6 @@
|
||||
"tsx"
|
||||
],
|
||||
"program": "scripts/test-standalone-core-api-server.ts",
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"env": {
|
||||
"PROTOBUS_PORT": "26040",
|
||||
"HOSTBRIDGE_PORT": "26041",
|
||||
@@ -156,7 +153,6 @@
|
||||
"--exit",
|
||||
"${file}"
|
||||
],
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"env": {
|
||||
"TS_NODE_PROJECT": "./tsconfig.unit-test.json",
|
||||
"NODE_ENV": "test",
|
||||
|
||||
@@ -64,9 +64,3 @@ old_docs/**
|
||||
e2e-build.mjs
|
||||
e2e.vsix
|
||||
test-results/
|
||||
|
||||
# Ignore Storybook files
|
||||
**/*.stories.tsx
|
||||
*storybook.log
|
||||
storybook-static
|
||||
**/StorybookDecorator.tsx
|
||||
-127
@@ -1,132 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## [3.32.6]
|
||||
|
||||
- Add experimental support for VSCode multi root workspaces
|
||||
- Add Claude Sonnet 4.5 to Claude Code provider
|
||||
- Add Glm 4.6 to Z AI provider
|
||||
|
||||
## [3.32.5]
|
||||
|
||||
- Improve thinking budget slider UI to take up less space
|
||||
- Fix Vercel provider cost note and sign-up url
|
||||
- Fix repeated API error 400 in SAP AI Core provider
|
||||
- Add us-west-1 to Amazon Bedrock regions
|
||||
- Fix OCA provider refresh logic
|
||||
|
||||
## [3.32.4]
|
||||
|
||||
- Add 1m context window support to Claude Sonnet 4.5
|
||||
- Add Claude Sonnet 4.5 to GCP Vertex
|
||||
- Add prompt caching support for OpenRouter accidental `anthropic/claude-4.5-sonnet` model ID
|
||||
|
||||
## [3.32.3]
|
||||
|
||||
- Add Claude Sonnet 4.5 to Bedrock provider
|
||||
- Add Alert banner for new Claude Sonnet 4.5 model
|
||||
|
||||
## [3.32.2]
|
||||
|
||||
- Add Claude Sonnet 4.5 to Cline/OpenRouter/Anthropic providers
|
||||
- Add /task deep link handler
|
||||
|
||||
## [3.32.1]
|
||||
|
||||
- Preserve reasoning traces for Cline/OpenRouter/Anthropic providers to maintain conversation integrity
|
||||
- Add automatically retry on rate limit errors with SAP AI Core provider
|
||||
- Fix Cline accounts using stale id token at refresh response
|
||||
- Minor UI improvements to Settings and Task Header
|
||||
|
||||
## [3.32.0]
|
||||
|
||||
- Added the new code-supernova-1-million stealth model, available for free and delivering a 1 million token context window
|
||||
- Changes to inform Cline about commands that are available on your system
|
||||
|
||||
## [3.31.1]
|
||||
|
||||
- Version bump
|
||||
|
||||
## [3.31.0]
|
||||
|
||||
- UI Improvements: New task header and focus chain design to take up less space for a cleaner experience
|
||||
- Voice Mode: Experimental feature that must be enabled in settings for hands-free coding
|
||||
- YOLO Mode: Enable in settings to let Cline approve all actions and automatically switch between plan/act mode
|
||||
- Fix Oracle Code Assist provider issues
|
||||
|
||||
## [3.30.3]
|
||||
|
||||
- Add Oracle Code Assist provider
|
||||
|
||||
## [3.30.2]
|
||||
|
||||
- Fix UI tests
|
||||
|
||||
## [3.30.1]
|
||||
|
||||
- Fix model list not being updated in time for user to use shortcut button to update model to stealth model
|
||||
- Fix flicker issue when switching modes
|
||||
- Fix Sticky header in settings view overlaping with content on scroll
|
||||
- Add experimental yolo mode feature that disables all user approvals and automatically executes a task and navigates through plan to act mode until the task is complete
|
||||
|
||||
## [3.30.0]
|
||||
|
||||
- Add code-supernova stealth model
|
||||
|
||||
## [3.29.2]
|
||||
|
||||
- Fix: Reverted change that caused formatting issues
|
||||
- Fix: Moonshot - Pass max_tokens value to provider
|
||||
|
||||
## [3.29.1]
|
||||
|
||||
- Changeset bump + Announcement banner update
|
||||
|
||||
## [3.29.0]
|
||||
|
||||
- Updated Baseten provider to fetch models from server
|
||||
- Fix: Updated insufficient balance URL for easy Cline balance top-ups
|
||||
- Accessibility: Improvements to screen readers in MCP, Cline Rules, workflows, and history views.
|
||||
|
||||
## [3.28.4]
|
||||
|
||||
- Fix bug where some Windows machines had API request hanging
|
||||
- Fix bug where 'Proceed while running' action button would be disabled after running an interactive command
|
||||
- Fix prompt cache info not being displayed in History
|
||||
|
||||
## [3.28.3]
|
||||
|
||||
- Fixed issue with start new task button
|
||||
- Feature to generate commit message for staged changes, with unstaged as fallback
|
||||
|
||||
## [3.28.2]
|
||||
|
||||
- Fix for focus chain settings
|
||||
|
||||
## [3.28.1]
|
||||
|
||||
- Requesty: use base URL to get models and API keys
|
||||
- Removed focus chain feature flag
|
||||
|
||||
## [3.28.0]
|
||||
|
||||
- Synchronized Task History: Real-time task history synchronization across all Cline instances
|
||||
- Optimized GPT-5 Integration: Fine-tuned system prompts for improved performance with GPT-5 model family
|
||||
- Deep Planning Improvements: Optimized prompts for Windows/PowerShell environments and dependency exclusion
|
||||
- Streamlined UI Experience: ESC key navigation, cleaner approve/reject buttons, and improved editor panel focus
|
||||
- Smart Provider Search: Improved search functionality in API provider dropdown for faster model selection
|
||||
- Added per-provider thinking tokens configurability
|
||||
- Added Ollama custom prompt options
|
||||
- Enhanced SAP AI Core Provider: Orchestration mode support and improved model visibility
|
||||
- Added Dify.ai API Integration
|
||||
- SambaNova Updates: Added DeepSeek-V3.1 model
|
||||
- Better Gemini rate limit handling
|
||||
- OpenAI Reasoning Effort: Minimal reasoning effort configuration for OpenAI models
|
||||
- Fixed LiteLLM Caching: Anthropic caching compatibility when using LiteLLM
|
||||
- Fixed Ollama default endpoint connections
|
||||
- Fixed AutoApprove menu overflow
|
||||
- Fixed extended thinking token issue with Anthropic models
|
||||
- Fixed issue with slash commands removing text from prompt
|
||||
|
||||
## [3.27.2]
|
||||
|
||||
- Remove `grok-code-fast-1` promotion deadline
|
||||
|
||||
+1
-30
@@ -74,6 +74,7 @@ We also welcome contributions to our [documentation](https://github.com/cline/cl
|
||||
4. Testing
|
||||
- Run `npm run test` to run tests locally.
|
||||
- Before submitting PR, run `npm run format:fix` to format your code
|
||||
- Run `npm run test:ci` to run tests locally
|
||||
|
||||
### Extension
|
||||
|
||||
@@ -156,36 +157,6 @@ Anyone can contribute code to Cline, but we ask that you follow these guidelines
|
||||
- Update existing tests if your changes affect them
|
||||
- Include both unit tests and integration tests where appropriate
|
||||
|
||||
**End-to-End (E2E) Testing**
|
||||
|
||||
Cline includes comprehensive E2E tests using Playwright that simulate real user interactions with the extension in VS Code:
|
||||
|
||||
- **Running E2E tests:**
|
||||
```bash
|
||||
npm run test:e2e # Build and run all E2E tests
|
||||
npm run e2e # Run tests without rebuilding
|
||||
npm run test:e2e -- --debug # Run with interactive debugger
|
||||
```
|
||||
|
||||
- **Writing E2E tests:**
|
||||
- Tests are located in `src/test/e2e/`
|
||||
- Use the `e2e` fixture for single-root workspace tests
|
||||
- Use `e2eMultiRoot` fixture for multi-root workspace tests
|
||||
- Follow existing patterns in `auth.test.ts`, `chat.test.ts`, `diff.test.ts`, and `editor.test.ts`
|
||||
- See `src/test/e2e/README.md` for detailed documentation
|
||||
|
||||
- **Debug mode features:**
|
||||
- Interactive Playwright Inspector for step-by-step debugging
|
||||
- Record new interactions and generate test code automatically
|
||||
- Visual VS Code instance for manual testing
|
||||
- Element inspection and selector validation
|
||||
|
||||
- **Test environment:**
|
||||
- Automated VS Code setup with Cline extension loaded
|
||||
- Mock API server for backend testing
|
||||
- Temporary workspaces with test fixtures
|
||||
- Video recording for failed tests
|
||||
|
||||
4. **Version Management with Changesets**
|
||||
|
||||
- Create a changeset for any user-facing changes using `npm run changeset`
|
||||
|
||||
+1
-2
@@ -123,8 +123,7 @@
|
||||
"!**/node_modules/**",
|
||||
"!**/webview-ui/build/**",
|
||||
"!**/generated/**",
|
||||
"!**/proto/**",
|
||||
"!**/tests/specs/**"
|
||||
"!**/proto/**"
|
||||
]
|
||||
},
|
||||
"plugins": [
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
cline-core-debug.log
|
||||
bin/*
|
||||
@@ -1,6 +0,0 @@
|
||||
/_____/\ /_/\ /_______/\/__/\ /__/\ /_____/\
|
||||
\:::__\/ \:\ \ \__.::._\/\::\_\\ \ \\::::_\/_
|
||||
\:\ \ __\:\ \ \::\ \ \:. `-\ \ \\:\/___/\
|
||||
\:\ \/_/\\:\ \____ _\::\ \__\:. _ \ \\::___\/_
|
||||
\:\_\ \ \\:\/___/\/__\::\__/\\. \`-\ \ \\:\____/\
|
||||
\_____\/ \_____\/\________\/ \__\/ \__\/ \_____\/
|
||||
@@ -1,71 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/cline/cli/pkg/hostbridge"
|
||||
)
|
||||
|
||||
var (
|
||||
port int
|
||||
verbose bool
|
||||
)
|
||||
|
||||
func main() {
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "cline-host",
|
||||
Short: "Cline Host Bridge Service",
|
||||
Long: `A simple host bridge service that provides host operations for Cline Core.`,
|
||||
RunE: runServer,
|
||||
}
|
||||
|
||||
rootCmd.Flags().IntVarP(&port, "port", "p", 51052, "port to listen on")
|
||||
rootCmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "verbose logging")
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func runServer(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
// Create gRPC hostbridge service
|
||||
service := hostbridge.NewGrpcServer(port, verbose)
|
||||
|
||||
// Handle graceful shutdown
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
go func() {
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sigChan
|
||||
|
||||
if verbose {
|
||||
log.Println("Shutting down hostbridge server...")
|
||||
}
|
||||
|
||||
cancel()
|
||||
}()
|
||||
|
||||
// Start server
|
||||
if verbose {
|
||||
log.Printf("Starting Cline Host Bridge on port %d", port)
|
||||
}
|
||||
|
||||
// Run the service
|
||||
if err := service.Start(ctx); err != nil {
|
||||
return fmt.Errorf("failed to run service: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/cline/cli/pkg/cli"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
coreAddress string
|
||||
verbose bool
|
||||
outputFormat string
|
||||
)
|
||||
|
||||
func main() {
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "cline",
|
||||
Short: "Cline CLI - AI-powered coding assistant",
|
||||
Long: `A command-line interface for interacting with Cline AI coding assistant.
|
||||
|
||||
This CLI provides access to Cline's task management, configuration, and
|
||||
monitoring capabilities from the terminal.`,
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
if outputFormat != "rich" && outputFormat != "json" && outputFormat != "plain" {
|
||||
return fmt.Errorf("invalid output format '%s': must be one of 'rich', 'json', or 'plain'", outputFormat)
|
||||
}
|
||||
|
||||
return global.InitializeGlobalConfig(&global.GlobalConfig{
|
||||
Verbose: verbose,
|
||||
OutputFormat: outputFormat,
|
||||
CoreAddress: coreAddress,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
rootCmd.PersistentFlags().StringVar(&coreAddress, "address", fmt.Sprintf("localhost:%d", common.DEFAULT_CLINE_CORE_PORT), "Cline Core gRPC address")
|
||||
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output")
|
||||
rootCmd.PersistentFlags().StringVarP(&outputFormat, "output-format", "o", "rich", "output format (rich|json|plain)")
|
||||
|
||||
rootCmd.AddCommand(cli.NewTaskCommand())
|
||||
rootCmd.AddCommand(cli.NewInstanceCommand())
|
||||
rootCmd.AddCommand(cli.NewVersionCommand())
|
||||
rootCmd.AddCommand(cli.NewAuthCommand())
|
||||
rootCmd.AddCommand(cli.NewTaskSendCommand())
|
||||
|
||||
if err := rootCmd.ExecuteContext(context.Background()); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/cline/cli/pkg/common"
|
||||
)
|
||||
|
||||
// 2. Multi-instance start: default_instance remains the first started.
|
||||
func TestMultiInstanceDefaultUnchanged(t *testing.T) {
|
||||
_ = setTempClineDir(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Start first instance and wait healthy
|
||||
_ = mustRunCLI(ctx, t, "instance", "new")
|
||||
out1 := listInstancesJSON(ctx, t)
|
||||
if len(out1.CoreInstances) != 1 {
|
||||
t.Fatalf("expected 1 instance, got %d", len(out1.CoreInstances))
|
||||
}
|
||||
firstAddr := out1.CoreInstances[0].Address
|
||||
waitForAddressHealthy(t, firstAddr, defaultTimeout)
|
||||
|
||||
// Start second instance
|
||||
_ = mustRunCLI(ctx, t, "instance", "new")
|
||||
out2 := listInstancesJSON(ctx, t)
|
||||
if len(out2.CoreInstances) < 2 {
|
||||
t.Fatalf("expected at least 2 instances, got %d", len(out2.CoreInstances))
|
||||
}
|
||||
|
||||
// Default should remain the first started address
|
||||
if out2.DefaultInstance != firstAddr {
|
||||
t.Fatalf("default changed; expected %s, got %s", firstAddr, out2.DefaultInstance)
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Default.json update after removal of current default
|
||||
func TestDefaultJsonUpdateAfterRemoval(t *testing.T) {
|
||||
_ = setTempClineDir(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Start two instances
|
||||
_ = mustRunCLI(ctx, t, "instance", "new")
|
||||
_ = mustRunCLI(ctx, t, "instance", "new")
|
||||
|
||||
out := listInstancesJSON(ctx, t)
|
||||
if len(out.CoreInstances) < 2 {
|
||||
t.Fatalf("expected at least 2 instances, got %d", len(out.CoreInstances))
|
||||
}
|
||||
|
||||
// Choose second as new default
|
||||
target := out.CoreInstances[1]
|
||||
waitForAddressHealthy(t, target.Address, defaultTimeout)
|
||||
|
||||
// Set as default
|
||||
_ = mustRunCLI(ctx, t, "instance", "use", target.Address)
|
||||
|
||||
// Verify default switched
|
||||
out = listInstancesJSON(ctx, t)
|
||||
if out.DefaultInstance != target.Address {
|
||||
t.Fatalf("default_instance not updated to %s (got %s)", target.Address, out.DefaultInstance)
|
||||
}
|
||||
|
||||
// Kill the default instance using runtime PID discovery
|
||||
corePID := getCorePID(t, target.Address)
|
||||
if corePID <= 0 {
|
||||
t.Fatalf("could not find PID for core process at %s", target.Address)
|
||||
}
|
||||
t.Logf("Killing cline-core process PID %d for instance %s", corePID, target.Address)
|
||||
if err := syscall.Kill(corePID, syscall.SIGKILL); err != nil {
|
||||
t.Fatalf("kill pid %d: %v", corePID, err)
|
||||
}
|
||||
|
||||
// Wait for removal
|
||||
waitForAddressRemoved(t, target.Address, longTimeout)
|
||||
|
||||
// Clean up dangling host process (SIGKILL leaves these behind by design)
|
||||
t.Logf("Cleaning up dangling host process on port %d", target.HostPort())
|
||||
findAndKillHostProcess(t, target.HostPort())
|
||||
|
||||
// Ensure default_instance updated to another available instance (or removed if none remain)
|
||||
out = listInstancesJSON(ctx, t)
|
||||
|
||||
// If there are instances left, default_instance must be one of them
|
||||
if len(out.CoreInstances) > 0 {
|
||||
found := false
|
||||
for _, it := range out.CoreInstances {
|
||||
if out.DefaultInstance == it.Address {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("default_instance %s not set to an existing instance after removal", out.DefaultInstance)
|
||||
}
|
||||
} else {
|
||||
// No instances remain; cli-default-instance.json should be removed
|
||||
clineDir := getClineDir(t)
|
||||
defPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "settings", "cli-default-instance.json")
|
||||
if _, err := os.Stat(defPath); err == nil {
|
||||
t.Fatalf("expected cli-default-instance.json removed when no instances remain")
|
||||
}
|
||||
}
|
||||
|
||||
// Also verify cli-default-instance.json on disk reflects the in-memory default (if any)
|
||||
clineDir := getClineDir(t)
|
||||
defPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "settings", "cli-default-instance.json")
|
||||
if len(out.CoreInstances) > 0 {
|
||||
raw, err := os.ReadFile(defPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read cli-default-instance.json: %v", err)
|
||||
}
|
||||
var tmp struct {
|
||||
DefaultInstance string `json:"default_instance"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &tmp); err != nil {
|
||||
t.Fatalf("unmarshal cli-default-instance.json: %v", err)
|
||||
}
|
||||
if tmp.DefaultInstance != out.DefaultInstance {
|
||||
t.Fatalf("cli-default-instance.json mismatch: file=%s list=%s", tmp.DefaultInstance, out.DefaultInstance)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 11. SQLite database missing (edge): list succeeds and returns empty set
|
||||
func TestRegistryDirMissingEdge(t *testing.T) {
|
||||
clineDir := setTempClineDir(t)
|
||||
|
||||
// Remove the settings directory entirely (which contains locks.db)
|
||||
settingsDir := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER)
|
||||
if err := os.RemoveAll(settingsDir); err != nil {
|
||||
t.Fatalf("RemoveAll(%s): %v", common.SETTINGS_SUBFOLDER, err)
|
||||
}
|
||||
|
||||
// Listing should succeed and return empty results
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
|
||||
defer cancel()
|
||||
out := listInstancesJSON(ctx, t)
|
||||
if len(out.CoreInstances) != 0 {
|
||||
t.Fatalf("expected 0 instances after removing %s dir, got %d", common.SETTINGS_SUBFOLDER, len(out.CoreInstances))
|
||||
}
|
||||
|
||||
// Ensure cli-default-instance.json not present
|
||||
defPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "settings", "cli-default-instance.json")
|
||||
if _, err := os.Stat(defPath); err == nil {
|
||||
t.Fatalf("expected no cli-default-instance.json after removing %s dir", common.SETTINGS_SUBFOLDER)
|
||||
}
|
||||
}
|
||||
@@ -1,378 +0,0 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/common"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultTimeout = 30 * time.Second
|
||||
longTimeout = 60 * time.Second
|
||||
pollInterval = 250 * time.Millisecond
|
||||
instancesBinRel = "../bin/cline"
|
||||
)
|
||||
|
||||
func repoAwareBinPath(t *testing.T) string {
|
||||
// Tests live in repoRoot/cli/e2e. Binary is at repoRoot/cli/bin/cline
|
||||
t.Helper()
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("Getwd error: %v", err)
|
||||
}
|
||||
// cli/e2e -> cli/bin/cline
|
||||
p := filepath.Clean(filepath.Join(wd, instancesBinRel))
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
t.Fatalf("CLI binary not found at %s; run `npm run compile-cli` first: %v", p, err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func setTempClineDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
clineDir := filepath.Join(dir, ".cline")
|
||||
if err := os.MkdirAll(clineDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir clineDir: %v", err)
|
||||
}
|
||||
t.Setenv("CLINE_DIR", clineDir)
|
||||
return clineDir
|
||||
}
|
||||
|
||||
func runCLI(ctx context.Context, t *testing.T, args ...string) (string, string, int) {
|
||||
t.Helper()
|
||||
bin := repoAwareBinPath(t)
|
||||
|
||||
// Ensure CLI uses the same CLINE_DIR as the tests by passing --config=<CLINE_DIR>
|
||||
// (InitializeGlobalConfig uses ConfigPath as the base directory for registry.)
|
||||
if clineDir := os.Getenv("CLINE_DIR"); clineDir != "" && !contains(args, "--config") {
|
||||
// Prepend persistent flag so Cobra sees it regardless of subcommand position
|
||||
args = append([]string{"--config", clineDir}, args...)
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, bin, args...)
|
||||
// Run CLI from repo root so relative paths inside CLI (./cli/bin/...) resolve
|
||||
if wd, err := os.Getwd(); err == nil {
|
||||
repoRoot := filepath.Clean(filepath.Join(wd, "..", ".."))
|
||||
cmd.Dir = repoRoot
|
||||
}
|
||||
// propagate env including CLINE_DIR
|
||||
cmd.Env = os.Environ()
|
||||
outB, errB := &strings.Builder{}, &strings.Builder{}
|
||||
cmd.Stdout = outB
|
||||
cmd.Stderr = errB
|
||||
err := cmd.Run()
|
||||
exit := 0
|
||||
if err != nil {
|
||||
// Extract exit code if possible
|
||||
if ee, ok := err.(*exec.ExitError); ok {
|
||||
exit = ee.ExitCode()
|
||||
} else {
|
||||
exit = -1
|
||||
}
|
||||
}
|
||||
return outB.String(), errB.String(), exit
|
||||
}
|
||||
|
||||
func mustRunCLI(ctx context.Context, t *testing.T, args ...string) string {
|
||||
t.Helper()
|
||||
out, errOut, exit := runCLI(ctx, t, args...)
|
||||
if exit != 0 {
|
||||
t.Fatalf("cline %v failed (exit=%d)\nstdout:\n%s\nstderr:\n%s", args, exit, out, errOut)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func listInstancesJSON(ctx context.Context, t *testing.T) common.InstancesOutput {
|
||||
t.Helper()
|
||||
// Trigger CLI to perform cleanup/health by invoking list (table output is ignored)
|
||||
_ = mustRunCLI(ctx, t, "instance", "list")
|
||||
|
||||
// Read from SQLite locks database to build structured output
|
||||
clineDir := getClineDir(t)
|
||||
|
||||
// Load default instance from settings file
|
||||
defaultInstance := readDefaultInstanceFromSettings(t, clineDir)
|
||||
|
||||
// Load instances from SQLite
|
||||
instances := readInstancesFromSQLite(t, clineDir)
|
||||
|
||||
return common.InstancesOutput{
|
||||
DefaultInstance: defaultInstance,
|
||||
CoreInstances: instances,
|
||||
}
|
||||
}
|
||||
|
||||
func hasAddress(in common.InstancesOutput, addr string) bool {
|
||||
for _, it := range in.CoreInstances {
|
||||
if it.Address == addr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func getByAddress(in common.InstancesOutput, addr string) (common.CoreInstanceInfo, bool) {
|
||||
for _, it := range in.CoreInstances {
|
||||
if it.Address == addr {
|
||||
return it, true
|
||||
}
|
||||
}
|
||||
return common.CoreInstanceInfo{}, false
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, timeout time.Duration, cond func() (bool, string)) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
ok, msg := cond()
|
||||
if ok {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("waitFor timeout: %s", msg)
|
||||
}
|
||||
time.Sleep(pollInterval)
|
||||
}
|
||||
}
|
||||
|
||||
func waitForAddressHealthy(t *testing.T, addr string, timeout time.Duration) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
t.Logf("Waiting for gRPC health check on %s...", addr)
|
||||
|
||||
waitFor(t, timeout, func() (bool, string) {
|
||||
if common.IsInstanceHealthy(ctx, addr) {
|
||||
return true, ""
|
||||
}
|
||||
return false, fmt.Sprintf("gRPC health check failed for %s", addr)
|
||||
})
|
||||
|
||||
t.Logf("gRPC health check passed for %s", addr)
|
||||
}
|
||||
|
||||
func waitForAddressRemoved(t *testing.T, addr string, timeout time.Duration) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
waitFor(t, timeout, func() (bool, string) {
|
||||
out := listInstancesJSON(ctx, t)
|
||||
if hasAddress(out, addr) {
|
||||
return false, fmt.Sprintf("address %s still present", addr)
|
||||
}
|
||||
return true, ""
|
||||
})
|
||||
}
|
||||
|
||||
func findFreePort(t *testing.T) int {
|
||||
t.Helper()
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen 127.0.0.1:0: %v", err)
|
||||
}
|
||||
defer l.Close()
|
||||
_, portStr, _ := net.SplitHostPort(l.Addr().String())
|
||||
var port int
|
||||
fmt.Sscanf(portStr, "%d", &port)
|
||||
return port
|
||||
}
|
||||
|
||||
func getClineDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
clineDir := os.Getenv("CLINE_DIR")
|
||||
if clineDir == "" {
|
||||
t.Fatalf("CLINE_DIR not set")
|
||||
}
|
||||
return clineDir
|
||||
}
|
||||
|
||||
// isPortInUse checks if a port is currently in use by any process
|
||||
func isPortInUse(port int) bool {
|
||||
conn, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
|
||||
if err != nil {
|
||||
return true // Port is in use
|
||||
}
|
||||
conn.Close()
|
||||
return false // Port is free
|
||||
}
|
||||
|
||||
// waitForPortClosed waits for a port to become free (no process listening)
|
||||
func waitForPortClosed(t *testing.T, port int, timeout time.Duration) {
|
||||
t.Helper()
|
||||
waitFor(t, timeout, func() (bool, string) {
|
||||
if isPortInUse(port) {
|
||||
return false, fmt.Sprintf("port %d still in use", port)
|
||||
}
|
||||
return true, ""
|
||||
})
|
||||
}
|
||||
|
||||
// waitForPortsClosed waits for both core and host ports to become free
|
||||
func waitForPortsClosed(t *testing.T, corePort, hostPort int, timeout time.Duration) {
|
||||
t.Helper()
|
||||
waitFor(t, timeout, func() (bool, string) {
|
||||
if isPortInUse(corePort) {
|
||||
return false, fmt.Sprintf("core port %d still in use", corePort)
|
||||
}
|
||||
if isPortInUse(hostPort) {
|
||||
return false, fmt.Sprintf("host port %d still in use", hostPort)
|
||||
}
|
||||
return true, ""
|
||||
})
|
||||
}
|
||||
|
||||
// findAndKillHostProcess finds and kills any process listening on the host port
|
||||
// This is used to clean up dangling host processes after SIGKILL tests
|
||||
func findAndKillHostProcess(t *testing.T, hostPort int) {
|
||||
t.Helper()
|
||||
// Use lsof to find process listening on the host port
|
||||
cmd := exec.Command("lsof", "-ti", fmt.Sprintf(":%d", hostPort))
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
// No process found on port - that's fine
|
||||
return
|
||||
}
|
||||
|
||||
pidStr := strings.TrimSpace(string(output))
|
||||
if pidStr == "" {
|
||||
return
|
||||
}
|
||||
|
||||
var pid int
|
||||
if _, err := fmt.Sscanf(pidStr, "%d", &pid); err != nil {
|
||||
t.Logf("Warning: could not parse PID from lsof output: %s", pidStr)
|
||||
return
|
||||
}
|
||||
|
||||
if pid > 0 {
|
||||
t.Logf("Cleaning up dangling host process PID %d on port %d", pid, hostPort)
|
||||
if err := syscall.Kill(pid, syscall.SIGKILL); err != nil {
|
||||
t.Logf("Warning: failed to kill dangling host process %d: %v", pid, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getPIDByPort returns the PID of the process listening on the specified port (fallback method)
|
||||
func getPIDByPort(t *testing.T, port int) int {
|
||||
t.Helper()
|
||||
cmd := exec.Command("lsof", "-ti", fmt.Sprintf(":%d", port))
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return 0 // Process not found
|
||||
}
|
||||
|
||||
pidStr := strings.TrimSpace(string(output))
|
||||
if pidStr == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
pid, err := strconv.Atoi(pidStr)
|
||||
if err != nil {
|
||||
t.Logf("Warning: could not parse PID from lsof output: %s", pidStr)
|
||||
return 0
|
||||
}
|
||||
|
||||
return pid
|
||||
}
|
||||
|
||||
// getCorePIDViaRPC returns the PID of the cline-core process using RPC (preferred method)
|
||||
func getCorePIDViaRPC(t *testing.T, address string) int {
|
||||
t.Helper()
|
||||
|
||||
// Initialize global config to access registry
|
||||
clineDir := os.Getenv("CLINE_DIR")
|
||||
if clineDir == "" {
|
||||
t.Logf("Warning: CLINE_DIR not set, falling back to lsof")
|
||||
return getCorePIDViaLsof(t, address)
|
||||
}
|
||||
|
||||
cfg := &global.GlobalConfig{
|
||||
ConfigPath: clineDir,
|
||||
}
|
||||
|
||||
if err := global.InitializeGlobalConfig(cfg); err != nil {
|
||||
t.Logf("Warning: failed to initialize global config, falling back to lsof: %v", err)
|
||||
return getCorePIDViaLsof(t, address)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Get client for the address
|
||||
client, err := global.Clients.GetRegistry().GetClient(ctx, address)
|
||||
if err != nil {
|
||||
t.Logf("Warning: failed to get client for %s, falling back to lsof: %v", address, err)
|
||||
return getCorePIDViaLsof(t, address)
|
||||
}
|
||||
|
||||
// Call GetProcessInfo RPC
|
||||
processInfo, err := client.State.GetProcessInfo(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
t.Logf("Warning: GetProcessInfo RPC failed for %s, falling back to lsof: %v", address, err)
|
||||
return getCorePIDViaLsof(t, address)
|
||||
}
|
||||
|
||||
return int(processInfo.ProcessId)
|
||||
}
|
||||
|
||||
// getCorePIDViaLsof returns the PID using lsof (fallback method)
|
||||
func getCorePIDViaLsof(t *testing.T, address string) int {
|
||||
t.Helper()
|
||||
_, portStr, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
t.Logf("Warning: invalid address format %s", address)
|
||||
return 0
|
||||
}
|
||||
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
t.Logf("Warning: invalid port in address %s", address)
|
||||
return 0
|
||||
}
|
||||
|
||||
return getPIDByPort(t, port)
|
||||
}
|
||||
|
||||
// getCorePID returns the PID of the cline-core process for the given address
|
||||
// Uses RPC first, falls back to lsof if RPC fails
|
||||
func getCorePID(t *testing.T, address string) int {
|
||||
t.Helper()
|
||||
|
||||
// Try RPC first (preferred method)
|
||||
if pid := getCorePIDViaRPC(t, address); pid > 0 {
|
||||
return pid
|
||||
}
|
||||
|
||||
// Fall back to lsof if RPC fails
|
||||
return getCorePIDViaLsof(t, address)
|
||||
}
|
||||
|
||||
// getHostPID returns the PID of the cline-host process for the given host port
|
||||
func getHostPID(t *testing.T, hostPort int) int {
|
||||
t.Helper()
|
||||
return getPIDByPort(t, hostPort)
|
||||
}
|
||||
|
||||
// contains reports whether slice has the target string.
|
||||
func contains(slice []string, target string) bool {
|
||||
for _, s := range slice {
|
||||
if s == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestMain validates required artifacts exist before running E2E tests.
|
||||
// It does NOT build artifacts. Build manually via:
|
||||
//
|
||||
// npm run compile-standalone
|
||||
// npm run compile-cli
|
||||
func TestMain(m *testing.M) {
|
||||
// Determine repo root from cli/e2e
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "getwd: %v\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
repoRoot := filepath.Clean(filepath.Join(wd, "..", ".."))
|
||||
|
||||
cliBin := filepath.Join(repoRoot, "cli", "bin", "cline")
|
||||
coreJS := filepath.Join(repoRoot, "dist-standalone", "cline-core.js")
|
||||
|
||||
missing := []string{}
|
||||
if _, err := os.Stat(cliBin); err != nil {
|
||||
missing = append(missing, cliBin)
|
||||
}
|
||||
if _, err := os.Stat(coreJS); err != nil {
|
||||
missing = append(missing, coreJS)
|
||||
}
|
||||
|
||||
if len(missing) > 0 {
|
||||
if testing.Short() {
|
||||
// Optional quality-of-life: allow skipping with -short when artifacts are absent
|
||||
fmt.Fprintf(os.Stderr, "[e2e] skipping (-short) due to missing artifacts:\n %s\n", strings.Join(missing, "\n "))
|
||||
os.Exit(0)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "Missing required build artifacts for E2E tests:\n %s\n\nPlease build them first:\n npm run compile-standalone\n npm run compile-cli\n", strings.Join(missing, "\n "))
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/cline/cli/pkg/common"
|
||||
)
|
||||
|
||||
// 9. Mixed localhost vs 127.0.0.1 addresses coexist and are both healthy
|
||||
func TestMixedLocalhostVs127Coexist(t *testing.T) {
|
||||
clineDir := setTempClineDir(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Start one instance
|
||||
_ = mustRunCLI(ctx, t, "instance", "new")
|
||||
|
||||
// Get the running instance and its port/PID
|
||||
out := listInstancesJSON(ctx, t)
|
||||
if len(out.CoreInstances) == 0 {
|
||||
t.Fatalf("expected at least 1 instance")
|
||||
}
|
||||
inst := out.CoreInstances[0]
|
||||
waitForAddressHealthy(t, inst.Address, defaultTimeout)
|
||||
|
||||
// Manually add a SQLite entry for the same port but 127.0.0.1 host
|
||||
addr127 := fmt.Sprintf("127.0.0.1:%d", inst.CorePort())
|
||||
dbPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "locks.db")
|
||||
|
||||
if err := insertRemoteInstanceIntoSQLite(t, dbPath, addr127, inst.CorePort(), inst.HostPort()); err != nil {
|
||||
t.Fatalf("insert 127 alias entry: %v", err)
|
||||
}
|
||||
|
||||
// Verify both addresses appear and are healthy
|
||||
waitForAddressHealthy(t, inst.Address, defaultTimeout)
|
||||
waitForAddressHealthy(t, addr127, defaultTimeout)
|
||||
|
||||
out = listInstancesJSON(ctx, t)
|
||||
if !hasAddress(out, inst.Address) || !hasAddress(out, addr127) {
|
||||
t.Fatalf("expected both %s and %s present", inst.Address, addr127)
|
||||
}
|
||||
}
|
||||
|
||||
// 10. Start-stop stress: loop starting then killing instances; ensure no leftovers
|
||||
func TestStartStopStress(t *testing.T) {
|
||||
_ = setTempClineDir(t)
|
||||
|
||||
for i := 0; i < 3; i++ { // keep small for CI time
|
||||
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Snapshot current addresses
|
||||
before := listInstancesJSON(ctx, t)
|
||||
beforeSet := map[string]struct{}{}
|
||||
for _, it := range before.CoreInstances {
|
||||
beforeSet[it.Address] = struct{}{}
|
||||
}
|
||||
|
||||
// Start a new instance
|
||||
_ = mustRunCLI(ctx, t, "instance", "new")
|
||||
|
||||
// Find the new instance address
|
||||
var newAddr string
|
||||
waitFor(t, defaultTimeout, func() (bool, string) {
|
||||
after := listInstancesJSON(ctx, t)
|
||||
for _, it := range after.CoreInstances {
|
||||
if _, ok := beforeSet[it.Address]; !ok {
|
||||
newAddr = it.Address
|
||||
return true, ""
|
||||
}
|
||||
}
|
||||
return false, "new instance address not detected yet"
|
||||
})
|
||||
|
||||
// Wait healthy
|
||||
waitForAddressHealthy(t, newAddr, defaultTimeout)
|
||||
|
||||
// Get PID using runtime discovery and kill it
|
||||
after := listInstancesJSON(ctx, t)
|
||||
info, ok := getByAddress(after, newAddr)
|
||||
if !ok {
|
||||
t.Fatalf("new instance %s missing", newAddr)
|
||||
}
|
||||
|
||||
// Get PID using runtime discovery
|
||||
corePID := getCorePID(t, info.Address)
|
||||
if corePID <= 0 {
|
||||
t.Fatalf("could not find PID for new instance at %s", info.Address)
|
||||
}
|
||||
|
||||
t.Logf("Killing new instance %s (PID %d) for iteration %d", info.Address, corePID, i)
|
||||
if err := syscall.Kill(corePID, syscall.SIGKILL); err != nil {
|
||||
t.Fatalf("kill pid %d: %v", corePID, err)
|
||||
}
|
||||
|
||||
// Wait removed from SQLite database
|
||||
waitForAddressRemoved(t, newAddr, longTimeout)
|
||||
|
||||
// Verify instance is removed from SQLite database
|
||||
clineDir := os.Getenv("CLINE_DIR")
|
||||
if clineDir != "" {
|
||||
dbPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "locks.db")
|
||||
if verifyInstanceExistsInSQLite(t, dbPath, newAddr) {
|
||||
t.Fatalf("expected instance removed from SQLite database: %s", newAddr)
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up dangling host process (SIGKILL leaves these behind by design)
|
||||
t.Logf("Cleaning up dangling host process on port %d for iteration %d", info.HostPort(), i)
|
||||
findAndKillHostProcess(t, info.HostPort())
|
||||
|
||||
// Verify both ports are now free
|
||||
waitForPortsClosed(t, info.CorePort(), info.HostPort(), defaultTimeout)
|
||||
}
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/common"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"google.golang.org/grpc/health/grpc_health_v1"
|
||||
)
|
||||
|
||||
// readInstancesFromSQLite reads instances directly from the SQLite database for testing
|
||||
func readInstancesFromSQLite(t *testing.T, clineDir string) []common.CoreInstanceInfo {
|
||||
t.Helper()
|
||||
|
||||
dbPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "locks.db")
|
||||
|
||||
// Check if database exists
|
||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||
return []common.CoreInstanceInfo{}
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
t.Logf("Warning: Failed to open SQLite database: %v", err)
|
||||
return []common.CoreInstanceInfo{}
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Query instance locks
|
||||
query := common.SelectInstanceLockHoldersAscSQL
|
||||
|
||||
rows, err := db.Query(query)
|
||||
if err != nil {
|
||||
t.Logf("Warning: Failed to query instance locks: %v", err)
|
||||
return []common.CoreInstanceInfo{}
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var instances []common.CoreInstanceInfo
|
||||
for rows.Next() {
|
||||
var heldBy, lockTarget string
|
||||
var lockedAt int64
|
||||
|
||||
err := rows.Scan(&heldBy, &lockTarget, &lockedAt)
|
||||
if err != nil {
|
||||
t.Logf("Warning: Failed to scan lock row: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Create InstanceInfo
|
||||
info := common.CoreInstanceInfo{
|
||||
Address: heldBy,
|
||||
HostServiceAddress: lockTarget,
|
||||
Status: grpc_health_v1.HealthCheckResponse_UNKNOWN, // Will be updated by health check
|
||||
LastSeen: time.Unix(lockedAt/1000, 0), // Convert from milliseconds
|
||||
}
|
||||
|
||||
instances = append(instances, info)
|
||||
}
|
||||
|
||||
return instances
|
||||
}
|
||||
|
||||
// readDefaultInstanceFromSettings reads the default instance from the settings file
|
||||
func readDefaultInstanceFromSettings(t *testing.T, clineDir string) string {
|
||||
t.Helper()
|
||||
|
||||
settingsPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "settings", "cli-default-instance.json")
|
||||
|
||||
data, err := os.ReadFile(settingsPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return ""
|
||||
}
|
||||
t.Logf("Warning: Failed to read default instance file: %v", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
var tmp struct {
|
||||
DefaultInstance string `json:"default_instance"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &tmp); err != nil {
|
||||
t.Logf("Warning: Failed to parse default instance file: %v", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
return tmp.DefaultInstance
|
||||
}
|
||||
|
||||
// insertRemoteInstanceIntoSQLite inserts a remote instance entry directly into SQLite for testing
|
||||
func insertRemoteInstanceIntoSQLite(t *testing.T, dbPath, address string, corePort, hostPort int) error {
|
||||
t.Helper()
|
||||
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Initialize database schema for testing
|
||||
createTableSQL := `
|
||||
CREATE TABLE IF NOT EXISTS locks (
|
||||
id INTEGER PRIMARY KEY,
|
||||
held_by TEXT NOT NULL,
|
||||
lock_type TEXT NOT NULL CHECK (lock_type IN ('file', 'instance', 'folder')),
|
||||
lock_target TEXT NOT NULL,
|
||||
locked_at INTEGER NOT NULL,
|
||||
UNIQUE(lock_type, lock_target)
|
||||
);
|
||||
`
|
||||
createIndexesSQL := `
|
||||
CREATE INDEX IF NOT EXISTS idx_locks_held_by ON locks(held_by);
|
||||
CREATE INDEX IF NOT EXISTS idx_locks_type ON locks(lock_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_locks_target ON locks(lock_target);
|
||||
`
|
||||
|
||||
if _, err := db.Exec(createTableSQL); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(createIndexesSQL); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Insert the remote instance
|
||||
hostAddress := "remote.example.com:0"
|
||||
if hostPort != 0 {
|
||||
hostAddress = "remote.example.com:" + strconv.Itoa(hostPort)
|
||||
}
|
||||
|
||||
insertSQL := `INSERT INTO locks (held_by, lock_type, lock_target, locked_at) VALUES (?, 'instance', ?, ?)`
|
||||
_, err = db.Exec(insertSQL, address, hostAddress, time.Now().Unix()*1000)
|
||||
return err
|
||||
}
|
||||
|
||||
// verifyInstanceExistsInSQLite checks if an instance exists in the SQLite database
|
||||
func verifyInstanceExistsInSQLite(t *testing.T, dbPath, address string) bool {
|
||||
t.Helper()
|
||||
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
t.Logf("Failed to open database: %v", err)
|
||||
return false
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
query := `SELECT COUNT(*) FROM locks WHERE held_by = ? AND lock_type = 'instance'`
|
||||
var count int
|
||||
err = db.QueryRow(query, address).Scan(&count)
|
||||
if err != nil {
|
||||
t.Logf("Failed to query database: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
return count > 0
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"syscall"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestStartAndList verifies self-registration and default.json semantics in a fresh CLINE_DIR.
|
||||
func TestStartAndList(t *testing.T) {
|
||||
clineDir := setTempClineDir(t)
|
||||
t.Logf("Using temp CLINE_DIR: %s", clineDir)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
|
||||
defer cancel()
|
||||
|
||||
t.Logf("Starting new instance...")
|
||||
// Start a new instance
|
||||
startOutput := mustRunCLI(ctx, t, "instance", "new")
|
||||
t.Logf("Instance start output: %s", startOutput)
|
||||
|
||||
t.Logf("Listing instances to check registration...")
|
||||
// It should appear healthy in list JSON and be the default.
|
||||
out := listInstancesJSON(ctx, t)
|
||||
t.Logf("Found %d instances after start", len(out.CoreInstances))
|
||||
|
||||
if len(out.CoreInstances) != 1 {
|
||||
t.Fatalf("expected 1 instance, got %d", len(out.CoreInstances))
|
||||
}
|
||||
|
||||
addr := out.CoreInstances[0].Address
|
||||
t.Logf("Instance address: %s, status: %s", addr, out.CoreInstances[0].Status)
|
||||
|
||||
t.Logf("Waiting for address %s to become healthy...", addr)
|
||||
waitForAddressHealthy(t, addr, defaultTimeout)
|
||||
t.Logf("Address %s is now healthy", addr)
|
||||
|
||||
t.Logf("Checking default instance configuration...")
|
||||
// Default should be set to the new instance.
|
||||
out = listInstancesJSON(ctx, t)
|
||||
t.Logf("Default instance: %s", out.DefaultInstance)
|
||||
|
||||
if out.DefaultInstance == "" {
|
||||
t.Fatalf("default_instance not set")
|
||||
}
|
||||
if out.DefaultInstance != out.CoreInstances[0].Address {
|
||||
t.Fatalf("expected default_instance=%s, got %s", out.CoreInstances[0].Address, out.DefaultInstance)
|
||||
}
|
||||
|
||||
t.Logf("TestStartAndList completed successfully")
|
||||
}
|
||||
|
||||
// TestTaskNewDefault ensures tasks route to default instance.
|
||||
func TestTaskNewDefault(t *testing.T) {
|
||||
_ = setTempClineDir(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Start one instance and wait for healthy
|
||||
_ = mustRunCLI(ctx, t, "instance", "new")
|
||||
out := listInstancesJSON(ctx, t)
|
||||
if len(out.CoreInstances) != 1 {
|
||||
t.Fatalf("expected 1 instance, got %d", len(out.CoreInstances))
|
||||
}
|
||||
addr := out.CoreInstances[0].Address
|
||||
waitForAddressHealthy(t, addr, defaultTimeout)
|
||||
|
||||
// Create a new task at default (success is sufficient)
|
||||
_ = mustRunCLI(ctx, t, "task", "new", "hello world")
|
||||
}
|
||||
|
||||
// TestExplicitAddressAutoStart verifies that giving an explicit address auto-starts an instance and routes the task.
|
||||
func TestExplicitAddressAutoStart(t *testing.T) {
|
||||
_ = setTempClineDir(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Find a free port and use explicit address. This should auto-start an instance.
|
||||
port := findFreePort(t)
|
||||
addr := "localhost:" + itoa(port)
|
||||
|
||||
// Run a task at explicit address (auto-start path)
|
||||
_ = mustRunCLI(ctx, t, "task", "new", "--address", "localhost:"+itoa(port), "explicit address task")
|
||||
|
||||
// Verify the instance is present and healthy
|
||||
waitForAddressHealthy(t, addr, defaultTimeout)
|
||||
}
|
||||
|
||||
// TestCrashCleanup verifies that after SIGKILL of a local core, the cleanup removes the registry entry.
|
||||
// Also tests graceful shutdown (SIGTERM) vs crash cleanup and ensures no dangling host processes.
|
||||
func TestCrashCleanup(t *testing.T) {
|
||||
_ = setTempClineDir(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Start two instances for testing both graceful and crash scenarios
|
||||
_ = mustRunCLI(ctx, t, "instance", "new")
|
||||
_ = mustRunCLI(ctx, t, "instance", "new")
|
||||
|
||||
out := listInstancesJSON(ctx, t)
|
||||
if len(out.CoreInstances) < 2 {
|
||||
t.Fatalf("expected at least 2 instances, got %d", len(out.CoreInstances))
|
||||
}
|
||||
|
||||
// Test 1: Graceful shutdown (SIGTERM) - should clean up both processes
|
||||
gracefulTarget := out.CoreInstances[0]
|
||||
waitForAddressHealthy(t, gracefulTarget.Address, defaultTimeout)
|
||||
|
||||
// Get PID using runtime discovery
|
||||
gracefulPID := getCorePID(t, gracefulTarget.Address)
|
||||
if gracefulPID <= 0 {
|
||||
t.Fatalf("could not find PID for graceful target at %s", gracefulTarget.Address)
|
||||
}
|
||||
|
||||
t.Logf("Testing graceful shutdown (SIGTERM) for instance %s (PID %d)", gracefulTarget.Address, gracefulPID)
|
||||
if err := syscall.Kill(gracefulPID, syscall.SIGTERM); err != nil {
|
||||
t.Fatalf("kill SIGTERM pid %d: %v", gracefulPID, err)
|
||||
}
|
||||
|
||||
// Wait for registry cleanup
|
||||
waitForAddressRemoved(t, gracefulTarget.Address, longTimeout)
|
||||
|
||||
// Verify both core and host ports are freed (no dangling processes)
|
||||
waitForPortsClosed(t, gracefulTarget.CorePort(), gracefulTarget.HostPort(), defaultTimeout)
|
||||
|
||||
// Verify the instance is removed from SQLite (no file to check anymore)
|
||||
// The waitForAddressRemoved already confirms the instance is gone from the registry
|
||||
|
||||
// Test 2: Crash cleanup (SIGKILL) - creates dangling host process that we must clean up
|
||||
crashTarget := out.CoreInstances[1]
|
||||
waitForAddressHealthy(t, crashTarget.Address, defaultTimeout)
|
||||
|
||||
// Get PID using runtime discovery
|
||||
crashPID := getCorePID(t, crashTarget.Address)
|
||||
if crashPID <= 0 {
|
||||
t.Fatalf("could not find PID for crash target at %s", crashTarget.Address)
|
||||
}
|
||||
|
||||
t.Logf("Testing crash cleanup (SIGKILL) for instance %s (PID %d)", crashTarget.Address, crashPID)
|
||||
if err := syscall.Kill(crashPID, syscall.SIGKILL); err != nil {
|
||||
t.Fatalf("kill SIGKILL pid %d: %v", crashPID, err)
|
||||
}
|
||||
|
||||
// Wait for registry cleanup
|
||||
waitForAddressRemoved(t, crashTarget.Address, longTimeout)
|
||||
|
||||
// Verify the instance is removed from SQLite (no file to check anymore)
|
||||
// The waitForAddressRemoved already confirms the instance is gone from the registry
|
||||
|
||||
// Clean up dangling host process (SIGKILL leaves these behind by design)
|
||||
t.Logf("Cleaning up dangling host process %s", crashTarget.HostServiceAddress)
|
||||
findAndKillHostProcess(t, crashTarget.HostPort())
|
||||
|
||||
// Verify both ports are now free
|
||||
waitForPortsClosed(t, crashTarget.CorePort(), crashTarget.HostPort(), defaultTimeout)
|
||||
}
|
||||
|
||||
// itoa is a small helper for readability
|
||||
func itoa(i int) string {
|
||||
return strconvItoa(i)
|
||||
}
|
||||
|
||||
// minimal inline int->string to avoid extra imports in helpers
|
||||
func strconvItoa(i int) string {
|
||||
// simple fast path
|
||||
return fmtInt(i)
|
||||
}
|
||||
|
||||
func fmtInt(i int) string {
|
||||
// allocate small buffer; ints here are short
|
||||
return (func(n int) string {
|
||||
return fmt.Sprintf("%d", n)
|
||||
})(i)
|
||||
}
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
module github.com/cline/cli
|
||||
|
||||
go 1.23.0
|
||||
|
||||
require (
|
||||
github.com/atotto/clipboard v0.1.4
|
||||
github.com/cline/grpc-go v0.0.0
|
||||
github.com/mattn/go-sqlite3 v1.14.24
|
||||
github.com/spf13/cobra v1.8.0
|
||||
google.golang.org/grpc v1.75.0
|
||||
google.golang.org/protobuf v1.36.6
|
||||
)
|
||||
|
||||
replace github.com/cline/grpc-go => ../src/generated/grpc-go
|
||||
|
||||
require (
|
||||
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/glamour v0.10.0 // indirect
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.8.0 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||
github.com/muesli/reflow v0.3.0 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/yuin/goldmark v1.7.8 // indirect
|
||||
github.com/yuin/goldmark-emoji v1.0.5 // indirect
|
||||
golang.org/x/net v0.41.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/term v0.32.0 // indirect
|
||||
golang.org/x/text v0.26.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect
|
||||
)
|
||||
-101
@@ -1,101 +0,0 @@
|
||||
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
|
||||
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||
github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY=
|
||||
github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
||||
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
|
||||
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
||||
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
||||
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
|
||||
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
|
||||
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
|
||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
||||
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
||||
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 h1:pFyd6EwwL2TqFf8emdthzeX+gZE1ElRq3iM8pui4KBY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||
google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4=
|
||||
google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -1,125 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var isSessionAuthenticated bool
|
||||
|
||||
func NewAuthCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "auth",
|
||||
Short: "Sign in to Cline",
|
||||
Long: `Complete the authentication flow in browser to sign in to Cline.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return handleAuthCommand(cmd.Context())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func handleAuthCommand(ctx context.Context) error {
|
||||
fmt.Print("Authenticating with Cline...\n")
|
||||
if IsAuthenticated(ctx) {
|
||||
return signOutDialog(ctx)
|
||||
}
|
||||
|
||||
if err := signIn(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("You are signed in!")
|
||||
return nil
|
||||
}
|
||||
|
||||
func signOut(ctx context.Context) error {
|
||||
client, err := global.GetDefaultClient(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err = client.Account.AccountLogoutClicked(ctx, &cline.EmptyRequest{}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
isSessionAuthenticated = false
|
||||
fmt.Println("You have been signed out of Cline.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func signOutDialog(ctx context.Context) error {
|
||||
fmt.Print("You are already signed in to Cline.\nWould you like to sign out? (y/N): ")
|
||||
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
if !scanner.Scan() {
|
||||
return nil
|
||||
}
|
||||
|
||||
response := strings.ToLower(strings.TrimSpace(scanner.Text()))
|
||||
if response == "y" || response == "yes" {
|
||||
if err := signOut(ctx); err != nil {
|
||||
fmt.Printf("Failed to sign out: %v\n", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func signIn(ctx context.Context) error {
|
||||
if IsAuthenticated(ctx) {
|
||||
return nil
|
||||
}
|
||||
|
||||
verboseLog("Ensuring default instance exists...")
|
||||
if err := ensureDefaultInstance(ctx); err != nil {
|
||||
verboseLog("Failed to ensure default instance: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
verboseLog("Default instance ensured successfully.")
|
||||
time.Sleep(2 * time.Second) // Allow services to start
|
||||
|
||||
client, err := global.GetDefaultClient(ctx)
|
||||
if err != nil {
|
||||
verboseLog("Failed to obtain client: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = client.Account.AccountLoginClicked(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
verboseLog("Failed to login: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
isSessionAuthenticated = true
|
||||
verboseLog("Login successful")
|
||||
return nil
|
||||
}
|
||||
|
||||
func IsAuthenticated(ctx context.Context) bool {
|
||||
if isSessionAuthenticated {
|
||||
return true
|
||||
}
|
||||
|
||||
client, err := global.GetDefaultClient(ctx)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
_, err = client.Account.GetUserCredits(ctx, &cline.EmptyRequest{})
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func verboseLog(format string, args ...interface{}) {
|
||||
if global.Config != nil && global.Config.Verbose {
|
||||
fmt.Printf("[VERBOSE] "+format+"\n", args...)
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
)
|
||||
|
||||
// MessageDeduplicator handles message deduplication to prevent duplicate displays
|
||||
type MessageDeduplicator struct {
|
||||
mu sync.RWMutex
|
||||
seenMessages map[string]time.Time
|
||||
maxAge time.Duration
|
||||
cleanupTicker *time.Ticker
|
||||
}
|
||||
|
||||
// NewMessageDeduplicator creates a new message deduplicator
|
||||
func NewMessageDeduplicator() *MessageDeduplicator {
|
||||
d := &MessageDeduplicator{
|
||||
seenMessages: make(map[string]time.Time),
|
||||
maxAge: 5 * time.Minute, // Keep messages for 5 minutes
|
||||
cleanupTicker: time.NewTicker(1 * time.Minute), // Cleanup every minute
|
||||
}
|
||||
|
||||
// Start cleanup goroutine
|
||||
go d.cleanup()
|
||||
|
||||
return d
|
||||
}
|
||||
|
||||
// IsDuplicate checks if a message is a duplicate
|
||||
func (d *MessageDeduplicator) IsDuplicate(msg *types.ClineMessage) bool {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
// Create a hash of the message content
|
||||
hash := d.hashMessage(msg)
|
||||
|
||||
// Check if we've seen this message recently
|
||||
if lastSeen, exists := d.seenMessages[hash]; exists {
|
||||
// If we've seen it within the last few seconds, it's a duplicate
|
||||
if time.Since(lastSeen) < 2*time.Second {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Mark this message as seen
|
||||
d.seenMessages[hash] = time.Now()
|
||||
return false
|
||||
}
|
||||
|
||||
// hashMessage creates a hash of the message for deduplication
|
||||
func (d *MessageDeduplicator) hashMessage(msg *types.ClineMessage) string {
|
||||
// Create a hash based on message content, type, and timestamp
|
||||
content := fmt.Sprintf("%s|%s|%s|%d",
|
||||
string(msg.Type),
|
||||
msg.Say,
|
||||
msg.Ask,
|
||||
msg.Timestamp)
|
||||
|
||||
// For partial messages, include the text content in the hash
|
||||
if msg.Partial {
|
||||
content += "|" + msg.Text
|
||||
}
|
||||
|
||||
hash := md5.Sum([]byte(content))
|
||||
return fmt.Sprintf("%x", hash)
|
||||
}
|
||||
|
||||
// cleanup removes old entries from the seen messages map
|
||||
func (d *MessageDeduplicator) cleanup() {
|
||||
for range d.cleanupTicker.C {
|
||||
d.mu.Lock()
|
||||
now := time.Now()
|
||||
|
||||
// Remove entries older than maxAge
|
||||
for hash, timestamp := range d.seenMessages {
|
||||
if now.Sub(timestamp) > d.maxAge {
|
||||
delete(d.seenMessages, hash)
|
||||
}
|
||||
}
|
||||
|
||||
d.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Stop stops the cleanup goroutine
|
||||
func (d *MessageDeduplicator) Stop() {
|
||||
if d.cleanupTicker != nil {
|
||||
d.cleanupTicker.Stop()
|
||||
}
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/glamour"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
type Renderer struct {
|
||||
typewriter *TypewriterPrinter
|
||||
}
|
||||
|
||||
func NewRenderer() *Renderer {
|
||||
return &Renderer{
|
||||
typewriter: NewTypewriterPrinter(DefaultTypewriterConfig()),
|
||||
}
|
||||
}
|
||||
|
||||
// RenderMessage renders a message with timestamp and prefix
|
||||
func (r *Renderer) RenderMessage(timestamp, prefix, text string) error {
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
cleanText := r.sanitizeText(text)
|
||||
if cleanText == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
r.typewriter.PrintMessageLine(timestamp, prefix, cleanText)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenderTextWithMarkdown renders text with markdown styling in rich mode, plain text otherwise
|
||||
func (r *Renderer) RenderTextWithMarkdown(timestamp, text string) error {
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
cleanText := r.sanitizeText(text)
|
||||
if cleanText == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if we're in rich mode
|
||||
if global.Config.OutputFormat == "rich" {
|
||||
// Render markdown using Glamour
|
||||
renderer, err := glamour.NewTermRenderer(
|
||||
glamour.WithStylePath("dark"),
|
||||
glamour.WithWordWrap(80),
|
||||
)
|
||||
if err != nil {
|
||||
// Fallback to plain rendering if Glamour fails
|
||||
return r.RenderMessage(timestamp, "🤖", cleanText)
|
||||
}
|
||||
|
||||
rendered, err := renderer.Render(cleanText)
|
||||
if err != nil {
|
||||
// Fallback to plain rendering if rendering fails
|
||||
return r.RenderMessage(timestamp, "🤖", cleanText)
|
||||
}
|
||||
|
||||
// Print timestamp and then the rendered markdown
|
||||
fmt.Printf("[%s] 🤖:\n%s\n", timestamp, rendered)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fall back to plain text rendering
|
||||
return r.RenderMessage(timestamp, "ASST TEXT", cleanText)
|
||||
}
|
||||
|
||||
// RenderCommand renders a command execution
|
||||
func (r *Renderer) RenderCommand(timestamp, command string, isExecuting bool) error {
|
||||
if isExecuting {
|
||||
r.typewriter.PrintMessageLine(timestamp, "EXEC", command)
|
||||
} else {
|
||||
r.typewriter.PrintMessageLine(timestamp, "CMD", command)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatNumber formats numbers with k/m abbreviations
|
||||
func formatNumber(n int) string {
|
||||
if n >= 1000000 {
|
||||
return fmt.Sprintf("%.1fm", float64(n)/1000000.0)
|
||||
} else if n >= 1000 {
|
||||
return fmt.Sprintf("%.1fk", float64(n)/1000.0)
|
||||
}
|
||||
return fmt.Sprintf("%d", n)
|
||||
}
|
||||
|
||||
// formatUsageInfo formats token usage information (extracted from RenderAPI)
|
||||
func (r *Renderer) formatUsageInfo(tokensIn, tokensOut, cacheReads, cacheWrites int, cost float64) string {
|
||||
tokenDetails := fmt.Sprintf("[tokens in: %s, out: %s; cache read: %s, write: %s]",
|
||||
formatNumber(tokensIn),
|
||||
formatNumber(tokensOut),
|
||||
formatNumber(cacheReads),
|
||||
formatNumber(cacheWrites))
|
||||
|
||||
return fmt.Sprintf("%s ($%.4f)", tokenDetails, cost)
|
||||
}
|
||||
|
||||
// RenderAPI renders API request information
|
||||
func (r *Renderer) RenderAPI(timestamp, status string, apiInfo *types.APIRequestInfo) error {
|
||||
if apiInfo.Cost >= 0 {
|
||||
message := fmt.Sprintf("%s %s", status, r.formatUsageInfo(apiInfo.TokensIn, apiInfo.TokensOut, apiInfo.CacheReads, apiInfo.CacheWrites, apiInfo.Cost))
|
||||
r.typewriter.PrintMessageLine(timestamp, "API INFO", message)
|
||||
} else {
|
||||
r.typewriter.PrintMessageLine(timestamp, "API INFO", status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenderRetry renders retry information
|
||||
func (r *Renderer) RenderRetry(timestamp string, attempt, maxAttempts, delaySec int) error {
|
||||
message := fmt.Sprintf("Retrying failed attempt %d/%d", attempt, maxAttempts)
|
||||
if delaySec > 0 {
|
||||
message += fmt.Sprintf(" in %d seconds", delaySec)
|
||||
}
|
||||
message += "..."
|
||||
r.typewriter.PrintMessageLine(timestamp, "API INFO", message)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenderTaskList displays task history with improved formatting
|
||||
func (r *Renderer) RenderTaskList(tasks []*cline.TaskItem) error {
|
||||
const maxTasks = 20
|
||||
|
||||
startIndex := 0
|
||||
if len(tasks) > maxTasks {
|
||||
startIndex = len(tasks) - maxTasks
|
||||
}
|
||||
|
||||
recentTasks := tasks[startIndex:]
|
||||
|
||||
r.typewriter.PrintfLn("=== Task History (showing last %d of %d total tasks) ===\n", len(recentTasks), len(tasks))
|
||||
|
||||
for i, task := range recentTasks {
|
||||
r.typewriter.PrintfLn("Task ID: %s", task.Id)
|
||||
|
||||
description := task.Task
|
||||
if len(description) > 1000 {
|
||||
description = description[:1000] + "..."
|
||||
}
|
||||
r.typewriter.PrintfLn("Message: %s", description)
|
||||
|
||||
usageInfo := r.formatUsageInfo(int(task.TokensIn), int(task.TokensOut), int(task.CacheReads), int(task.CacheWrites), task.TotalCost)
|
||||
r.typewriter.PrintfLn("Usage : %s", usageInfo)
|
||||
|
||||
// Single space between tasks (except last)
|
||||
if i < len(recentTasks)-1 {
|
||||
r.typewriter.PrintfLn("")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Renderer) RenderDebug(format string, args ...interface{}) error {
|
||||
if global.Config.Verbose {
|
||||
timestamp := time.Now().Format("15:04:05")
|
||||
message := fmt.Sprintf(format, args...)
|
||||
r.typewriter.PrintMessageLine(timestamp, "[DEBUG]", message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Renderer) ClearLine() {
|
||||
fmt.Print("\r\033[K")
|
||||
}
|
||||
|
||||
func (r *Renderer) MoveCursorUp(n int) {
|
||||
fmt.Printf("\033[%dA", n)
|
||||
}
|
||||
|
||||
func (r *Renderer) sanitizeText(text string) string {
|
||||
text = strings.TrimSpace(text)
|
||||
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Remove control characters and escape sequences
|
||||
var result strings.Builder
|
||||
for _, r := range text {
|
||||
// Keep printable characters, spaces, tabs, and newlines
|
||||
if r >= 32 || r == '\t' || r == '\n' || r == '\r' {
|
||||
result.WriteRune(r)
|
||||
}
|
||||
// Skip control characters (0-31 except tab, newline, carriage return)
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
func (r *Renderer) SetTypewriterEnabled(enabled bool) {
|
||||
r.typewriter.SetEnabled(enabled)
|
||||
}
|
||||
|
||||
func (r *Renderer) IsTypewriterEnabled() bool {
|
||||
return r.typewriter.IsEnabled()
|
||||
}
|
||||
|
||||
func (r *Renderer) SetTypewriterSpeed(multiplier float64) {
|
||||
r.typewriter.SetSpeed(multiplier)
|
||||
}
|
||||
|
||||
func (r *Renderer) GetTypewriter() *TypewriterPrinter {
|
||||
return r.typewriter
|
||||
}
|
||||
@@ -1,515 +0,0 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
"github.com/cline/cli/pkg/markdown"
|
||||
)
|
||||
|
||||
// StreamingDisplay manages streaming message display with deduplication
|
||||
type StreamingDisplay struct {
|
||||
mu sync.RWMutex
|
||||
state *types.ConversationState
|
||||
renderer *Renderer
|
||||
dedupe *MessageDeduplicator
|
||||
markdownRenderer *markdown.StreamRenderer
|
||||
}
|
||||
|
||||
// NewStreamingDisplay creates a new streaming display manager
|
||||
func NewStreamingDisplay(state *types.ConversationState, renderer *Renderer) *StreamingDisplay {
|
||||
sd := &StreamingDisplay{
|
||||
state: state,
|
||||
renderer: renderer,
|
||||
dedupe: NewMessageDeduplicator(),
|
||||
}
|
||||
|
||||
// Initialize markdown renderer if in rich mode
|
||||
if global.Config != nil && global.Config.OutputFormat == "rich" {
|
||||
if mdRenderer, err := markdown.NewStreamRenderer(); err == nil {
|
||||
sd.markdownRenderer = mdRenderer
|
||||
}
|
||||
}
|
||||
|
||||
return sd
|
||||
}
|
||||
|
||||
// HandlePartialMessage processes partial messages with streaming support
|
||||
func (s *StreamingDisplay) HandlePartialMessage(msg *types.ClineMessage) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
messageKey := fmt.Sprintf("%d", msg.Timestamp)
|
||||
timestamp := msg.GetTimestamp()
|
||||
|
||||
// Check for deduplication
|
||||
if s.dedupe.IsDuplicate(msg) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get current streaming state
|
||||
streamingMsg := s.state.GetStreamingMessage()
|
||||
|
||||
switch msg.Type {
|
||||
case types.MessageTypeAsk:
|
||||
return s.handleStreamingAsk(msg, messageKey, timestamp, streamingMsg)
|
||||
case types.MessageTypeSay:
|
||||
return s.handleStreamingSay(msg, messageKey, timestamp, streamingMsg)
|
||||
default:
|
||||
return s.renderer.RenderMessage(timestamp, "🤖", msg.Text)
|
||||
}
|
||||
}
|
||||
|
||||
// handleStreamingAsk handles streaming ASK messages
|
||||
func (s *StreamingDisplay) handleStreamingAsk(msg *types.ClineMessage, messageKey, timestamp string, streamingMsg *types.StreamingMessage) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
cleanText := s.renderer.sanitizeText(msg.Text)
|
||||
if cleanText == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if this is an update to the same ASK message
|
||||
if streamingMsg.CurrentKey == messageKey {
|
||||
// This is an update to the same ASK message - stream the changes
|
||||
if cleanText != streamingMsg.LastText {
|
||||
s.streamAskMessageUpdate(cleanText, streamingMsg.LastText, timestamp)
|
||||
s.state.SetStreamingMessage(messageKey, cleanText)
|
||||
}
|
||||
} else {
|
||||
// This is a new ASK message
|
||||
s.finishCurrentStream()
|
||||
s.streamAskMessage(cleanText, timestamp, true)
|
||||
s.state.SetStreamingMessage(messageKey, cleanText)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleStreamingSay handles streaming SAY messages
|
||||
func (s *StreamingDisplay) handleStreamingSay(msg *types.ClineMessage, messageKey, timestamp string, streamingMsg *types.StreamingMessage) error {
|
||||
switch msg.Say {
|
||||
case string(types.SayTypeText), string(types.SayTypeCompletionResult), string(types.SayTypeReasoning):
|
||||
return s.handleStreamingText(msg, messageKey, timestamp, streamingMsg)
|
||||
case string(types.SayTypeCommand):
|
||||
return s.handleStreamingCommand(msg, messageKey, timestamp, streamingMsg)
|
||||
case string(types.SayTypeCommandOutput):
|
||||
return s.handleStreamingCommandOutput(msg, messageKey, timestamp, streamingMsg)
|
||||
case string(types.SayTypeTool):
|
||||
return s.handleStreamingTool(msg, messageKey, timestamp, streamingMsg)
|
||||
case string(types.SayTypeShellIntegrationWarning):
|
||||
return s.handleShellIntegrationWarning(msg, messageKey, timestamp, streamingMsg)
|
||||
default:
|
||||
// For non-streaming message types, use regular display
|
||||
return s.renderer.RenderMessage(timestamp, s.getMessagePrefix(msg.Say), msg.Text)
|
||||
}
|
||||
}
|
||||
|
||||
// handleStreamingText handles streaming text messages
|
||||
func (s *StreamingDisplay) handleStreamingText(msg *types.ClineMessage, messageKey, timestamp string, streamingMsg *types.StreamingMessage) error {
|
||||
cleanText := s.renderer.sanitizeText(msg.Text)
|
||||
if cleanText == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if we've already displayed this exact message
|
||||
if streamingMsg.CurrentKey == messageKey && streamingMsg.LastText == cleanText {
|
||||
return nil // Duplicate - ignore it
|
||||
}
|
||||
|
||||
// Rich mode: use markdown streaming
|
||||
if s.markdownRenderer != nil {
|
||||
return s.handleMarkdownText(msg, messageKey, cleanText, streamingMsg)
|
||||
}
|
||||
|
||||
// Plain mode: use typewriter
|
||||
return s.handlePlainText(msg, messageKey, timestamp, cleanText, streamingMsg)
|
||||
}
|
||||
|
||||
// handleMarkdownText handles text messages in rich mode with markdown rendering
|
||||
func (s *StreamingDisplay) handleMarkdownText(msg *types.ClineMessage, messageKey, cleanText string, streamingMsg *types.StreamingMessage) error {
|
||||
// Check if this is an update to the same message
|
||||
if streamingMsg.CurrentKey == messageKey {
|
||||
// Incremental update
|
||||
if len(cleanText) > len(streamingMsg.LastText) && strings.HasPrefix(cleanText, streamingMsg.LastText) {
|
||||
newChars := cleanText[len(streamingMsg.LastText):]
|
||||
if err := s.markdownRenderer.WriteIncremental(newChars); err != nil {
|
||||
return fmt.Errorf("failed to write incremental markdown: %w", err)
|
||||
}
|
||||
s.state.SetStreamingMessage(messageKey, cleanText)
|
||||
} else {
|
||||
// Non-incremental change - this shouldn't happen often in streaming
|
||||
// Flush and start new
|
||||
s.finishCurrentStream()
|
||||
if err := s.markdownRenderer.WriteIncremental(cleanText); err != nil {
|
||||
return fmt.Errorf("failed to write markdown: %w", err)
|
||||
}
|
||||
s.state.SetStreamingMessage(messageKey, cleanText)
|
||||
}
|
||||
} else {
|
||||
// New message - flush previous and start new
|
||||
s.finishCurrentStream()
|
||||
if err := s.markdownRenderer.WriteIncremental(cleanText); err != nil {
|
||||
return fmt.Errorf("failed to write markdown: %w", err)
|
||||
}
|
||||
s.state.SetStreamingMessage(messageKey, cleanText)
|
||||
}
|
||||
|
||||
// If message is complete, flush the markdown
|
||||
if !msg.Partial {
|
||||
if err := s.markdownRenderer.FlushMessage(); err != nil {
|
||||
return fmt.Errorf("failed to flush markdown: %w", err)
|
||||
}
|
||||
s.state.SetStreamingMessage("", "")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handlePlainText handles text messages in plain mode with typewriter
|
||||
func (s *StreamingDisplay) handlePlainText(msg *types.ClineMessage, messageKey, timestamp, cleanText string, streamingMsg *types.StreamingMessage) error {
|
||||
// Check if this is an update to the same message
|
||||
if streamingMsg.CurrentKey == messageKey {
|
||||
// Show incremental changes
|
||||
if len(cleanText) > len(streamingMsg.LastText) && strings.HasPrefix(cleanText, streamingMsg.LastText) {
|
||||
// Show only the new characters with typewriter effect
|
||||
newChars := cleanText[len(streamingMsg.LastText):]
|
||||
s.typewriterPrint(newChars)
|
||||
s.state.SetStreamingMessage(messageKey, cleanText)
|
||||
} else {
|
||||
// Text changed in a non-incremental way - replace the line
|
||||
s.renderer.ClearLine()
|
||||
prefix := s.getMessagePrefix(msg.Say)
|
||||
s.renderer.typewriter.PrintfInstant("[%s] %s: ", timestamp, prefix)
|
||||
s.typewriterPrint(cleanText)
|
||||
s.state.SetStreamingMessage(messageKey, cleanText)
|
||||
}
|
||||
} else {
|
||||
// This is a new message
|
||||
s.finishCurrentStream()
|
||||
prefix := s.getMessagePrefix(msg.Say)
|
||||
s.renderer.typewriter.PrintfInstant("[%s] %s: ", timestamp, prefix)
|
||||
|
||||
// Add typewriter animation for new messages
|
||||
s.typewriterPrint(cleanText)
|
||||
|
||||
s.state.SetStreamingMessage(messageKey, cleanText)
|
||||
}
|
||||
|
||||
// If message is complete, add newline
|
||||
if !msg.Partial {
|
||||
fmt.Println()
|
||||
s.state.SetStreamingMessage("", "")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleStreamingCommand handles command execution messages
|
||||
func (s *StreamingDisplay) handleStreamingCommand(msg *types.ClineMessage, messageKey, timestamp string, streamingMsg *types.StreamingMessage) error {
|
||||
cleanText := s.renderer.sanitizeText(msg.Text)
|
||||
if cleanText == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Show command being executed with typewriter effect
|
||||
s.finishCurrentStream()
|
||||
s.renderer.typewriter.PrintfInstant("[%s] 🖥️ CMD: ", timestamp)
|
||||
s.typewriterPrint(cleanText)
|
||||
fmt.Println()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleStreamingCommandOutput handles streaming command output
|
||||
func (s *StreamingDisplay) handleStreamingCommandOutput(msg *types.ClineMessage, messageKey, timestamp string, streamingMsg *types.StreamingMessage) error {
|
||||
cleanText := s.renderer.sanitizeText(msg.Text)
|
||||
if cleanText == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if we've already displayed this exact message
|
||||
if streamingMsg.CurrentKey == messageKey && streamingMsg.LastText == cleanText {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if this is an update to the same message
|
||||
if streamingMsg.CurrentKey == messageKey {
|
||||
// Show incremental changes with typewriter effect
|
||||
if len(cleanText) > len(streamingMsg.LastText) && strings.HasPrefix(cleanText, streamingMsg.LastText) {
|
||||
newChars := cleanText[len(streamingMsg.LastText):]
|
||||
s.typewriterPrint(newChars)
|
||||
s.state.SetStreamingMessage(messageKey, cleanText)
|
||||
} else {
|
||||
// Non-incremental change - replace the line
|
||||
s.renderer.ClearLine()
|
||||
s.renderer.typewriter.PrintfInstant("[%s] 🖥️ OUT: ", timestamp)
|
||||
s.typewriterPrint(cleanText)
|
||||
s.state.SetStreamingMessage(messageKey, cleanText)
|
||||
}
|
||||
} else {
|
||||
// New command output message
|
||||
s.finishCurrentStream()
|
||||
s.renderer.typewriter.PrintfInstant("[%s] 🖥️ OUT: ", timestamp)
|
||||
s.typewriterPrint(cleanText)
|
||||
s.state.SetStreamingMessage(messageKey, cleanText)
|
||||
}
|
||||
|
||||
// If message is complete, add newline
|
||||
if !msg.Partial {
|
||||
fmt.Println()
|
||||
s.state.SetStreamingMessage("", "")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleShellIntegrationWarning handles shell integration warning messages
|
||||
func (s *StreamingDisplay) handleShellIntegrationWarning(msg *types.ClineMessage, messageKey, timestamp string, streamingMsg *types.StreamingMessage) error {
|
||||
cleanText := s.renderer.sanitizeText(msg.Text)
|
||||
if cleanText == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Show a more concise shell integration warning
|
||||
s.finishCurrentStream()
|
||||
s.renderer.typewriter.PrintfInstant("[%s] ℹ️ NOTE: ", timestamp)
|
||||
s.typewriterPrint("Command executed (output not streamed due to shell integration)")
|
||||
fmt.Println()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleStreamingTool handles streaming tool messages with deduplication
|
||||
func (s *StreamingDisplay) handleStreamingTool(msg *types.ClineMessage, messageKey, timestamp string, streamingMsg *types.StreamingMessage) error {
|
||||
cleanText := s.renderer.sanitizeText(msg.Text)
|
||||
if cleanText == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
formattedTool := s.formatToolMessage(cleanText)
|
||||
|
||||
// Check if this is the exact same tool message we just displayed
|
||||
if streamingMsg.LastToolMessage == formattedTool {
|
||||
return nil // Exact duplicate - ignore it
|
||||
}
|
||||
|
||||
// Check if this is a very similar tool message
|
||||
if streamingMsg.LastToolMessage != "" && s.isSimilarToolMessage(streamingMsg.LastToolMessage, formattedTool) {
|
||||
return nil // Similar duplicate - ignore it
|
||||
}
|
||||
|
||||
// This is a genuinely new/different tool message
|
||||
s.finishCurrentStream()
|
||||
fmt.Printf("[%s] 🔧 TOOL: %s\n", timestamp, formattedTool)
|
||||
|
||||
// Store the formatted tool message for deduplication
|
||||
s.state.StreamingMessage.LastToolMessage = formattedTool
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// streamAskMessage streams an ASK message in a natural format
|
||||
func (s *StreamingDisplay) streamAskMessage(text, timestamp string, isNew bool) {
|
||||
// Try to parse as JSON
|
||||
var askData types.AskData
|
||||
if err := s.parseJSON(text, &askData); err != nil {
|
||||
// Display as text but sanitized
|
||||
fmt.Printf("[%s] 🤖 ASK: %s", timestamp, text)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("[%s] 🤖 ASK: %s", timestamp, askData.Response)
|
||||
|
||||
// Display options if available
|
||||
if len(askData.Options) > 0 {
|
||||
fmt.Print("\n\nOptions:")
|
||||
for i, option := range askData.Options {
|
||||
fmt.Printf("\n%d. %s", i+1, option)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// streamAskMessageUpdate handles updates to an existing ASK message
|
||||
func (s *StreamingDisplay) streamAskMessageUpdate(newText, oldText, timestamp string) {
|
||||
var oldAskData, newAskData types.AskData
|
||||
|
||||
oldErr := s.parseJSON(oldText, &oldAskData)
|
||||
newErr := s.parseJSON(newText, &newAskData)
|
||||
|
||||
if oldErr != nil || newErr != nil {
|
||||
// Handle plain text incremental updates
|
||||
if len(newText) > len(oldText) && strings.HasPrefix(newText, oldText) {
|
||||
newChars := newText[len(oldText):]
|
||||
fmt.Print(newChars)
|
||||
} else {
|
||||
// Non-incremental change - clear line and reprint everything
|
||||
s.renderer.ClearLine()
|
||||
fmt.Printf("[%s] 🤖 ASK: %s", timestamp, newText)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle structured updates
|
||||
if len(newAskData.Response) > len(oldAskData.Response) && strings.HasPrefix(newAskData.Response, oldAskData.Response) {
|
||||
newChars := newAskData.Response[len(oldAskData.Response):]
|
||||
fmt.Print(newChars)
|
||||
} else if oldAskData.Response != newAskData.Response {
|
||||
s.renderer.ClearLine()
|
||||
fmt.Printf("[%s] 🤖 ASK: %s", timestamp, newAskData.Response)
|
||||
}
|
||||
|
||||
// Handle options changes
|
||||
if len(newAskData.Options) > len(oldAskData.Options) {
|
||||
if len(oldAskData.Options) == 0 {
|
||||
fmt.Print("\n\nOptions:")
|
||||
}
|
||||
|
||||
for i := len(oldAskData.Options); i < len(newAskData.Options); i++ {
|
||||
fmt.Printf("\n%d. %s", i+1, newAskData.Options[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// typewriterPrint displays text with a typewriter animation effect
|
||||
func (s *StreamingDisplay) typewriterPrint(text string) {
|
||||
// Use the renderer's typewriter for consistent animation
|
||||
s.renderer.typewriter.Print(text)
|
||||
}
|
||||
|
||||
// finishCurrentStream completes any ongoing streaming message
|
||||
func (s *StreamingDisplay) finishCurrentStream() {
|
||||
streamingMsg := s.state.GetStreamingMessage()
|
||||
if streamingMsg.CurrentKey != "" {
|
||||
//fmt.Println() // Add newline to finish the current streaming message
|
||||
s.state.SetStreamingMessage("", "")
|
||||
}
|
||||
}
|
||||
|
||||
// getMessagePrefix returns the appropriate prefix for a message type
|
||||
func (s *StreamingDisplay) getMessagePrefix(say string) string {
|
||||
switch say {
|
||||
case string(types.SayTypeCompletionResult):
|
||||
return "✅ RESULT"
|
||||
case string(types.SayTypeText):
|
||||
return "🤖"
|
||||
default:
|
||||
return "🤖"
|
||||
}
|
||||
}
|
||||
|
||||
// formatToolMessage formats tool call messages for better readability
|
||||
func (s *StreamingDisplay) formatToolMessage(text string) string {
|
||||
var toolCall map[string]interface{}
|
||||
if err := s.parseJSON(text, &toolCall); err == nil {
|
||||
if tool, ok := toolCall["tool"].(string); ok {
|
||||
parts := []string{tool}
|
||||
|
||||
if path, ok := toolCall["path"].(string); ok && path != "" {
|
||||
parts = append(parts, fmt.Sprintf("path=%s", path))
|
||||
}
|
||||
|
||||
if content, ok := toolCall["content"].(string); ok && content != "" {
|
||||
if len(content) > 50 {
|
||||
parts = append(parts, fmt.Sprintf("content=%s...", content[:50]))
|
||||
} else {
|
||||
parts = append(parts, fmt.Sprintf("content=%s", content))
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
}
|
||||
|
||||
// If not JSON or doesn't have expected structure, return truncated
|
||||
if len(text) > 100 {
|
||||
return text[:100] + "..."
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// isSimilarToolMessage checks if two tool messages are similar enough to be considered duplicates
|
||||
func (s *StreamingDisplay) isSimilarToolMessage(msg1, msg2 string) bool {
|
||||
parts1 := strings.Fields(msg1)
|
||||
parts2 := strings.Fields(msg2)
|
||||
|
||||
if len(parts1) == 0 || len(parts2) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// If the first word (tool name) is the same, check for similarity
|
||||
if parts1[0] == parts2[0] {
|
||||
// For file operations, check if the path is the same
|
||||
if strings.Contains(msg1, "path=") && strings.Contains(msg2, "path=") {
|
||||
path1 := s.extractPathFromToolMessage(msg1)
|
||||
path2 := s.extractPathFromToolMessage(msg2)
|
||||
|
||||
if path1 != "" && path1 == path2 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// For very similar content (>80% similarity), consider them duplicates
|
||||
similarity := s.calculateStringSimilarity(msg1, msg2)
|
||||
return similarity > 0.8
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// extractPathFromToolMessage extracts the path parameter from a tool message
|
||||
func (s *StreamingDisplay) extractPathFromToolMessage(msg string) string {
|
||||
parts := strings.Fields(msg)
|
||||
for _, part := range parts {
|
||||
if strings.HasPrefix(part, "path=") {
|
||||
return strings.TrimPrefix(part, "path=")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// calculateStringSimilarity calculates a simple similarity ratio between two strings
|
||||
func (s *StreamingDisplay) calculateStringSimilarity(s1, s2 string) float64 {
|
||||
if s1 == s2 {
|
||||
return 1.0
|
||||
}
|
||||
|
||||
if len(s1) == 0 || len(s2) == 0 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
shorter, longer := s1, s2
|
||||
if len(s1) > len(s2) {
|
||||
shorter, longer = s2, s1
|
||||
}
|
||||
|
||||
matches := 0
|
||||
for i, r := range shorter {
|
||||
if i < len(longer) && rune(longer[i]) == r {
|
||||
matches++
|
||||
}
|
||||
}
|
||||
|
||||
return float64(matches) / float64(len(longer))
|
||||
}
|
||||
|
||||
// parseJSON is a helper function to parse JSON with error handling
|
||||
func (s *StreamingDisplay) parseJSON(text string, v interface{}) error {
|
||||
return json.Unmarshal([]byte(text), v)
|
||||
}
|
||||
|
||||
// Cleanup cleans up streaming display resources
|
||||
func (s *StreamingDisplay) Cleanup() {
|
||||
if s.dedupe != nil {
|
||||
s.dedupe.Stop()
|
||||
}
|
||||
if s.markdownRenderer != nil {
|
||||
s.markdownRenderer.Close()
|
||||
}
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TypewriterConfig holds configuration for the typewriter effect
|
||||
type TypewriterConfig struct {
|
||||
BaseDelay time.Duration // Base delay between characters
|
||||
FastDelay time.Duration // Faster delay for common characters
|
||||
SlowDelay time.Duration // Slower delay for punctuation
|
||||
PauseDelay time.Duration // Pause after sentences
|
||||
Enabled bool // Whether typewriter effect is enabled
|
||||
RandomFactor float64 // Randomness factor (0.0 to 1.0)
|
||||
}
|
||||
|
||||
// DefaultTypewriterConfig returns the default typewriter configuration
|
||||
func DefaultTypewriterConfig() *TypewriterConfig {
|
||||
return &TypewriterConfig{
|
||||
BaseDelay: 15 * time.Millisecond,
|
||||
FastDelay: 8 * time.Millisecond,
|
||||
SlowDelay: 25 * time.Millisecond,
|
||||
PauseDelay: 150 * time.Millisecond,
|
||||
Enabled: false,
|
||||
RandomFactor: 0.3,
|
||||
}
|
||||
}
|
||||
|
||||
// TypewriterPrinter handles typewriter-style output
|
||||
type TypewriterPrinter struct {
|
||||
config *TypewriterConfig
|
||||
}
|
||||
|
||||
// NewTypewriterPrinter creates a new typewriter printer
|
||||
func NewTypewriterPrinter(config *TypewriterConfig) *TypewriterPrinter {
|
||||
if config == nil {
|
||||
config = DefaultTypewriterConfig()
|
||||
}
|
||||
return &TypewriterPrinter{
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
// Print prints text with typewriter effect
|
||||
func (tp *TypewriterPrinter) Print(text string) {
|
||||
if !tp.config.Enabled {
|
||||
fmt.Print(text)
|
||||
return
|
||||
}
|
||||
|
||||
tp.typewriterPrint(text)
|
||||
}
|
||||
|
||||
// Printf prints formatted text with typewriter effect
|
||||
func (tp *TypewriterPrinter) Printf(format string, args ...interface{}) {
|
||||
text := fmt.Sprintf(format, args...)
|
||||
tp.Print(text)
|
||||
}
|
||||
|
||||
// Println prints text with typewriter effect and adds a newline
|
||||
func (tp *TypewriterPrinter) Println(text string) {
|
||||
tp.Print(text + "\n")
|
||||
}
|
||||
|
||||
// PrintfLn prints formatted text with typewriter effect and adds a newline
|
||||
func (tp *TypewriterPrinter) PrintfLn(format string, args ...interface{}) {
|
||||
text := fmt.Sprintf(format, args...)
|
||||
tp.Println(text)
|
||||
}
|
||||
|
||||
// PrintInstant prints text immediately without typewriter effect
|
||||
func (tp *TypewriterPrinter) PrintInstant(text string) {
|
||||
fmt.Print(text)
|
||||
}
|
||||
|
||||
// PrintfInstant prints formatted text immediately without typewriter effect
|
||||
func (tp *TypewriterPrinter) PrintfInstant(format string, args ...interface{}) {
|
||||
fmt.Printf(format, args...)
|
||||
}
|
||||
|
||||
// typewriterPrint displays text with a typewriter animation effect
|
||||
func (tp *TypewriterPrinter) typewriterPrint(text string) {
|
||||
// Convert string to runes to handle Unicode properly
|
||||
runes := []rune(text)
|
||||
|
||||
for i, r := range runes {
|
||||
// Print the character
|
||||
fmt.Print(string(r))
|
||||
os.Stdout.Sync() // Force immediate output
|
||||
|
||||
// Don't add delay after the last character
|
||||
if i == len(runes)-1 {
|
||||
break
|
||||
}
|
||||
|
||||
// Determine delay based on character type
|
||||
delay := tp.getDelayForCharacter(r, i)
|
||||
|
||||
// Sleep for the calculated delay
|
||||
time.Sleep(delay)
|
||||
}
|
||||
}
|
||||
|
||||
// getDelayForCharacter returns the appropriate delay for a character
|
||||
func (tp *TypewriterPrinter) getDelayForCharacter(r rune, position int) time.Duration {
|
||||
var baseDelay time.Duration
|
||||
|
||||
switch {
|
||||
case r == '.' || r == '!' || r == '?':
|
||||
// Longer pause after sentence endings
|
||||
baseDelay = tp.config.PauseDelay
|
||||
case r == ',' || r == ';' || r == ':':
|
||||
// Medium pause after punctuation
|
||||
baseDelay = tp.config.SlowDelay
|
||||
case r == ' ':
|
||||
// Slightly faster for spaces
|
||||
baseDelay = tp.config.FastDelay
|
||||
case r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z':
|
||||
// Fast for common letters
|
||||
baseDelay = tp.config.FastDelay
|
||||
case r == '\n':
|
||||
// No delay for newlines
|
||||
return 0
|
||||
default:
|
||||
// Base delay for other characters
|
||||
baseDelay = tp.config.BaseDelay
|
||||
}
|
||||
|
||||
// Add randomness to make it feel more natural
|
||||
if tp.config.RandomFactor > 0 {
|
||||
// Simple pseudo-random based on position to ensure consistency
|
||||
randomFactor := 0.7 + (tp.config.RandomFactor * float64(position%7) / 6.0)
|
||||
baseDelay = time.Duration(float64(baseDelay) * randomFactor)
|
||||
}
|
||||
|
||||
return baseDelay
|
||||
}
|
||||
|
||||
// SetEnabled enables or disables the typewriter effect
|
||||
func (tp *TypewriterPrinter) SetEnabled(enabled bool) {
|
||||
tp.config.Enabled = enabled
|
||||
}
|
||||
|
||||
// IsEnabled returns whether the typewriter effect is enabled
|
||||
func (tp *TypewriterPrinter) IsEnabled() bool {
|
||||
return tp.config.Enabled
|
||||
}
|
||||
|
||||
// SetSpeed adjusts the typewriter speed (multiplier: 0.1 = very slow, 1.0 = normal, 2.0 = fast)
|
||||
func (tp *TypewriterPrinter) SetSpeed(multiplier float64) {
|
||||
if multiplier <= 0 {
|
||||
multiplier = 1.0
|
||||
}
|
||||
|
||||
tp.config.BaseDelay = time.Duration(float64(15*time.Millisecond) / multiplier)
|
||||
tp.config.FastDelay = time.Duration(float64(8*time.Millisecond) / multiplier)
|
||||
tp.config.SlowDelay = time.Duration(float64(25*time.Millisecond) / multiplier)
|
||||
tp.config.PauseDelay = time.Duration(float64(150*time.Millisecond) / multiplier)
|
||||
}
|
||||
|
||||
// PrintMessageLine prints a complete message line with typewriter effect
|
||||
func (tp *TypewriterPrinter) PrintMessageLine(timestamp, prefix, text string) {
|
||||
// Print the timestamp and prefix with 10-char padding
|
||||
tp.PrintfInstant("[%s] %-10s: ", timestamp, prefix)
|
||||
// Print the message text with typewriter effect
|
||||
tp.Println(text)
|
||||
}
|
||||
|
||||
// Global typewriter printer instance
|
||||
var globalTypewriter = NewTypewriterPrinter(DefaultTypewriterConfig())
|
||||
|
||||
// Global convenience functions that use the global typewriter instance
|
||||
|
||||
// TypewriterPrint prints text with typewriter effect using the global instance
|
||||
func TypewriterPrint(text string) {
|
||||
globalTypewriter.Print(text)
|
||||
}
|
||||
|
||||
// TypewriterPrintf prints formatted text with typewriter effect using the global instance
|
||||
func TypewriterPrintf(format string, args ...interface{}) {
|
||||
globalTypewriter.Printf(format, args...)
|
||||
}
|
||||
|
||||
// TypewriterPrintln prints text with typewriter effect and newline using the global instance
|
||||
func TypewriterPrintln(text string) {
|
||||
globalTypewriter.Println(text)
|
||||
}
|
||||
|
||||
// TypewriterPrintfLn prints formatted text with typewriter effect and newline using the global instance
|
||||
func TypewriterPrintfLn(format string, args ...interface{}) {
|
||||
globalTypewriter.PrintfLn(format, args...)
|
||||
}
|
||||
|
||||
// TypewriterPrintMessageLine prints a message line with typewriter effect using the global instance
|
||||
func TypewriterPrintMessageLine(timestamp, prefix, text string) {
|
||||
globalTypewriter.PrintMessageLine(timestamp, prefix, text)
|
||||
}
|
||||
|
||||
// SetGlobalTypewriterEnabled enables or disables the global typewriter effect
|
||||
func SetGlobalTypewriterEnabled(enabled bool) {
|
||||
globalTypewriter.SetEnabled(enabled)
|
||||
}
|
||||
|
||||
// SetGlobalTypewriterSpeed sets the speed of the global typewriter effect
|
||||
func SetGlobalTypewriterSpeed(multiplier float64) {
|
||||
globalTypewriter.SetSpeed(multiplier)
|
||||
}
|
||||
|
||||
// GetGlobalTypewriter returns the global typewriter instance
|
||||
func GetGlobalTypewriter() *TypewriterPrinter {
|
||||
return globalTypewriter
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
package global
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/common"
|
||||
)
|
||||
|
||||
// ClineClients manages Cline instances using the new registry system
|
||||
type ClineClients struct {
|
||||
registry *ClientRegistry
|
||||
}
|
||||
|
||||
// NewClineClients creates a new ClineClients instance
|
||||
func NewClineClients(configPath string) *ClineClients {
|
||||
registry := NewClientRegistry(configPath)
|
||||
return &ClineClients{
|
||||
registry: registry,
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize performs cleanup of stale instances
|
||||
func (c *ClineClients) Initialize(ctx context.Context) error {
|
||||
// Clean up stale entries (direct SQLite operations)
|
||||
_ = c.registry.CleanupStaleInstances(ctx)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartNewInstance starts a new Cline instance and waits for cline-core to self-register
|
||||
func (c *ClineClients) StartNewInstance(ctx context.Context) (*common.CoreInstanceInfo, error) {
|
||||
// Find available ports
|
||||
corePort, hostPort, err := common.FindAvailablePortPair()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to find available ports: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Starting new Cline instance on ports %d (core) and %d (host bridge)\n", corePort, hostPort)
|
||||
|
||||
// Start cline-host first
|
||||
hostCmd, err := startClineHost(hostPort, corePort)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to start cline-host: %w", err)
|
||||
}
|
||||
|
||||
// Start cline-core (it will register itself in SQLite locks database)
|
||||
coreCmd, err := startClineCore(corePort, hostPort)
|
||||
if err != nil {
|
||||
// Clean up host process if core fails to start
|
||||
if hostCmd != nil && hostCmd.Process != nil {
|
||||
hostCmd.Process.Kill()
|
||||
}
|
||||
return nil, fmt.Errorf("failed to start cline-core: %w", err)
|
||||
}
|
||||
|
||||
fullAddress := fmt.Sprintf("localhost:%d", corePort)
|
||||
fmt.Println("Waiting for services to start and self-register in SQLite...")
|
||||
|
||||
// Use RetryOperation to wait for instance to be ready
|
||||
var instance *common.CoreInstanceInfo
|
||||
err = common.RetryOperation(12, 5*time.Second, func() error {
|
||||
// Check if instance registered itself in SQLite
|
||||
foundInstance, err := c.registry.GetInstance(fullAddress)
|
||||
if err != nil || foundInstance == nil {
|
||||
return fmt.Errorf("instance not found in registry: %v", err)
|
||||
}
|
||||
|
||||
// Verify instance is healthy
|
||||
if !common.IsInstanceHealthy(ctx, fullAddress) {
|
||||
return fmt.Errorf("instance is registered but not healthy")
|
||||
}
|
||||
|
||||
// Success - store the instance for return
|
||||
instance = foundInstance
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
// Clean up both processes on failure
|
||||
if coreCmd != nil && coreCmd.Process != nil {
|
||||
fmt.Printf("Cleaning up core process (PID: %d)\n", coreCmd.Process.Pid)
|
||||
coreCmd.Process.Kill()
|
||||
}
|
||||
if hostCmd != nil && hostCmd.Process != nil {
|
||||
fmt.Printf("Cleaning up host process (PID: %d)\n", hostCmd.Process.Pid)
|
||||
hostCmd.Process.Kill()
|
||||
}
|
||||
return nil, fmt.Errorf("failed to start instance: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("✅ Services started and registered successfully!")
|
||||
fmt.Printf(" Address: %s\n", instance.Address)
|
||||
fmt.Printf(" Core Port: %d\n", instance.CorePort())
|
||||
fmt.Printf(" Host Bridge Port: %d\n", instance.HostPort())
|
||||
fmt.Printf(" Process PID: %d\n", coreCmd.Process.Pid)
|
||||
return instance, nil
|
||||
}
|
||||
|
||||
// StartNewInstanceAtPort starts a new Cline instance at the specified port and waits for self-registration
|
||||
func (c *ClineClients) StartNewInstanceAtPort(ctx context.Context, corePort int) (*common.CoreInstanceInfo, error) {
|
||||
// Find available host port (core port + 1000)
|
||||
hostPort := corePort + 1000
|
||||
coreAddress := fmt.Sprintf("localhost:%d", corePort)
|
||||
|
||||
// Check if the specified core port is available
|
||||
if common.IsInstanceHealthy(ctx, coreAddress) {
|
||||
return nil, fmt.Errorf("port %d is already in use by another Cline instance", corePort)
|
||||
}
|
||||
|
||||
fmt.Printf("Starting new Cline instance on ports %d (core) and %d (host bridge)\n", corePort, hostPort)
|
||||
|
||||
// Start cline-host first
|
||||
hostCmd, err := startClineHost(hostPort, corePort)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to start cline-host: %w", err)
|
||||
}
|
||||
|
||||
// Start cline-core (it will register itself in SQLite locks database)
|
||||
coreCmd, err := startClineCore(corePort, hostPort)
|
||||
if err != nil {
|
||||
// Clean up host process if core fails to start
|
||||
if hostCmd != nil && hostCmd.Process != nil {
|
||||
hostCmd.Process.Kill()
|
||||
}
|
||||
return nil, fmt.Errorf("failed to start cline-core: %w", err)
|
||||
}
|
||||
|
||||
fullAddress := fmt.Sprintf("localhost:%d", corePort)
|
||||
fmt.Println("Waiting for services to start and self-register in SQLite...")
|
||||
|
||||
// Use RetryOperation to wait for instance to be ready
|
||||
var instance *common.CoreInstanceInfo
|
||||
err = common.RetryOperation(12, 5*time.Second, func() error {
|
||||
// Check if instance registered itself in SQLite
|
||||
foundInstance, err := c.registry.GetInstance(fullAddress)
|
||||
if err != nil || foundInstance == nil {
|
||||
return fmt.Errorf("instance not found in registry: %v", err)
|
||||
}
|
||||
|
||||
// Verify instance is healthy
|
||||
if !common.IsInstanceHealthy(ctx, fullAddress) {
|
||||
return fmt.Errorf("instance is registered but not healthy")
|
||||
}
|
||||
|
||||
// Success - store the instance for return
|
||||
instance = foundInstance
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
// Clean up both processes on failure
|
||||
if coreCmd != nil && coreCmd.Process != nil {
|
||||
fmt.Printf("Cleaning up core process (PID: %d)\n", coreCmd.Process.Pid)
|
||||
coreCmd.Process.Kill()
|
||||
}
|
||||
if hostCmd != nil && hostCmd.Process != nil {
|
||||
fmt.Printf("Cleaning up host process (PID: %d)\n", hostCmd.Process.Pid)
|
||||
hostCmd.Process.Kill()
|
||||
}
|
||||
return nil, fmt.Errorf("failed to start instance at port %d: %w", corePort, err)
|
||||
}
|
||||
|
||||
fmt.Println("✅ Services started and registered successfully!")
|
||||
fmt.Printf(" Address: %s\n", instance.Address)
|
||||
fmt.Printf(" Core Port: %d\n", instance.CorePort())
|
||||
fmt.Printf(" Host Bridge Port: %d\n", instance.HostPort())
|
||||
fmt.Printf(" Process PID: %d\n", coreCmd.Process.Pid)
|
||||
return instance, nil
|
||||
}
|
||||
|
||||
// GetRegistry returns the client registry
|
||||
func (c *ClineClients) GetRegistry() *ClientRegistry {
|
||||
return c.registry
|
||||
}
|
||||
|
||||
// EnsureInstanceAtAddress ensures an instance exists at the given address, starting one if needed
|
||||
func (c *ClineClients) EnsureInstanceAtAddress(ctx context.Context, address string) error {
|
||||
// Expect host:port everywhere
|
||||
normalized := address
|
||||
if normalized == "" {
|
||||
normalized = fmt.Sprintf("localhost:%d", common.DEFAULT_CLINE_CORE_PORT)
|
||||
}
|
||||
|
||||
// Check if instance already exists at this address
|
||||
if c.registry.HasInstanceAtAddress(normalized) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse host:port
|
||||
host, port, err := common.ParseHostPort(normalized)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid address format %s", address)
|
||||
}
|
||||
|
||||
// Use IPv6-compatible localhost detection
|
||||
if common.IsLocalAddress(host) {
|
||||
_, err := c.StartNewInstanceAtPort(ctx, port)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start new instance at %s: %w", normalized, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot start remote instance at %s", normalized)
|
||||
}
|
||||
|
||||
func startClineHost(hostPort, corePort int) (*exec.Cmd, error) {
|
||||
fmt.Printf("Starting cline-host on port %d\n", hostPort)
|
||||
|
||||
// Start the cline-host process
|
||||
cmd := exec.Command("./cli/bin/cline-host",
|
||||
"--verbose",
|
||||
"--port", fmt.Sprintf("%d", hostPort))
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("failed to start cline-host: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Started cline-host (PID: %d)\n", cmd.Process.Pid)
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
func startClineCore(corePort, hostPort int) (*exec.Cmd, error) {
|
||||
fmt.Printf("Starting cline-core on port %d (with hostbridge on %d)\n", corePort, hostPort)
|
||||
|
||||
// Create port-tagged log file in OS temp directory with full address
|
||||
logFileName := fmt.Sprintf("cline-core-debug-localhost-%d.log", corePort)
|
||||
logFilePath := fmt.Sprintf("%s/%s", os.TempDir(), logFileName)
|
||||
logFile, err := os.Create(logFilePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create log file: %w", err)
|
||||
}
|
||||
|
||||
// Start the cline-core process with --config flag instead of CLINE_DIR env var
|
||||
args := []string{"cline-core.js",
|
||||
"--port", fmt.Sprintf("%d", corePort),
|
||||
"--host-bridge-port", fmt.Sprintf("%d", hostPort),
|
||||
"--config", Config.ConfigPath}
|
||||
|
||||
fmt.Printf("DEBUG: Starting cline-core with command: node %v\n", args)
|
||||
fmt.Printf("DEBUG: Working directory: ./dist-standalone\n")
|
||||
fmt.Printf("DEBUG: Config path: %s\n", Config.ConfigPath)
|
||||
|
||||
cmd := exec.Command("node", args...)
|
||||
|
||||
// Set working directory to dist-standalone (relative to project root)
|
||||
cmd.Dir = "./dist-standalone"
|
||||
|
||||
// Redirect stdout and stderr to log file
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
|
||||
// Set environment variables (removed CLINE_DIR)
|
||||
env := os.Environ()
|
||||
env = append(env,
|
||||
"GRPC_TRACE=all",
|
||||
"GRPC_VERBOSITY=DEBUG",
|
||||
"NODE_ENV=development",
|
||||
)
|
||||
cmd.Env = env
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
logFile.Close()
|
||||
return nil, fmt.Errorf("failed to start cline-core: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Started cline-core (PID: %d)\n", cmd.Process.Pid)
|
||||
fmt.Printf("Logging cline-core output to: %s\n", logFilePath)
|
||||
return cmd, nil
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
package global
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/cline/cli/pkg/common"
|
||||
"github.com/cline/grpc-go/client"
|
||||
)
|
||||
|
||||
type Port uint16
|
||||
|
||||
type GlobalConfig struct {
|
||||
ConfigPath string
|
||||
Verbose bool
|
||||
OutputFormat string
|
||||
CoreAddress string
|
||||
}
|
||||
|
||||
var (
|
||||
Config *GlobalConfig
|
||||
Clients *ClineClients
|
||||
)
|
||||
|
||||
func InitializeGlobalConfig(cfg *GlobalConfig) error {
|
||||
if cfg.ConfigPath == "" {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get home directory: %w", err)
|
||||
}
|
||||
cfg.ConfigPath = filepath.Join(homeDir, ".cline")
|
||||
}
|
||||
|
||||
// Ensure .cline directory exists
|
||||
if err := os.MkdirAll(cfg.ConfigPath, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create config directory: %w", err)
|
||||
}
|
||||
|
||||
Config = cfg
|
||||
Clients = NewClineClients(cfg.ConfigPath)
|
||||
|
||||
// Initialize the clients registry
|
||||
ctx := context.Background()
|
||||
if err := Clients.Initialize(ctx); err != nil {
|
||||
return fmt.Errorf("failed to initialize clients: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDefaultClient returns a client for the default instance or the address override
|
||||
func GetDefaultClient(ctx context.Context) (*client.ClineClient, error) {
|
||||
if Config.CoreAddress != "" && Config.CoreAddress != fmt.Sprintf("localhost:%d", common.DEFAULT_CLINE_CORE_PORT) {
|
||||
// User specified a specific address, use that
|
||||
return Clients.GetRegistry().GetClient(ctx, Config.CoreAddress)
|
||||
}
|
||||
|
||||
// Use the default instance from registry
|
||||
return Clients.GetRegistry().GetDefaultClient(ctx)
|
||||
}
|
||||
|
||||
// GetClientForAddress returns a client for a specific address
|
||||
func GetClientForAddress(ctx context.Context, address string) (*client.ClineClient, error) {
|
||||
return Clients.GetRegistry().GetClient(ctx, address)
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
package global
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/sqlite"
|
||||
"github.com/cline/cli/pkg/common"
|
||||
"github.com/cline/grpc-go/client"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
"github.com/cline/grpc-go/host"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/health/grpc_health_v1"
|
||||
)
|
||||
|
||||
// ClientRegistry manages Cline client connections using direct SQLite operations
|
||||
type ClientRegistry struct {
|
||||
lockManager *sqlite.LockManager
|
||||
configPath string
|
||||
}
|
||||
|
||||
// NewClientRegistry creates a new client registry
|
||||
func NewClientRegistry(configPath string) *ClientRegistry {
|
||||
lockManager, err := sqlite.NewLockManager(configPath)
|
||||
if err != nil {
|
||||
// Log error but continue - we can still function without SQLite
|
||||
log.Fatalf("Warning: Failed to initialize SQLite lock manager: %v\n", err)
|
||||
}
|
||||
|
||||
return &ClientRegistry{
|
||||
lockManager: lockManager,
|
||||
configPath: configPath,
|
||||
}
|
||||
}
|
||||
|
||||
// GetDefaultInstance returns the default instance address from settings file
|
||||
func (r *ClientRegistry) GetDefaultInstance() string {
|
||||
defaultAddr, err := sqlite.GetDefaultInstance(r.configPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return defaultAddr
|
||||
}
|
||||
|
||||
// SetDefaultInstance sets the default instance (writes default.json)
|
||||
func (r *ClientRegistry) SetDefaultInstance(address string) error {
|
||||
// Verify the instance exists in SQLite
|
||||
if r.lockManager != nil {
|
||||
exists, err := r.lockManager.HasInstanceAtAddress(address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check instance existence: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("instance %s not found in registry", address)
|
||||
}
|
||||
}
|
||||
|
||||
return sqlite.SetDefaultInstance(r.configPath, address)
|
||||
}
|
||||
|
||||
// GetInstance returns instance information directly from SQLite
|
||||
func (r *ClientRegistry) GetInstance(address string) (*common.CoreInstanceInfo, error) {
|
||||
if r.lockManager == nil {
|
||||
return nil, fmt.Errorf("lock manager not available")
|
||||
}
|
||||
|
||||
return r.lockManager.GetInstanceInfo(address)
|
||||
}
|
||||
|
||||
// GetClient returns a connected client for the given address (created on-demand)
|
||||
func (r *ClientRegistry) GetClient(ctx context.Context, address string) (*client.ClineClient, error) {
|
||||
// Verify instance exists in SQLite
|
||||
if r.lockManager != nil {
|
||||
exists, err := r.lockManager.HasInstanceAtAddress(address)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check instance existence: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("instance %s not found", address)
|
||||
}
|
||||
}
|
||||
|
||||
// Create client on-demand (no caching)
|
||||
target, err := common.NormalizeAddressForGRPC(address)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid address %s: %w", address, err)
|
||||
}
|
||||
|
||||
cl, err := client.NewClineClient(target)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create client for %s: %w", target, err)
|
||||
}
|
||||
|
||||
if err := cl.Connect(ctx); err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to %s: %w", target, err)
|
||||
}
|
||||
|
||||
return cl, nil
|
||||
}
|
||||
|
||||
// GetDefaultClient returns a client for the default instance
|
||||
func (r *ClientRegistry) GetDefaultClient(ctx context.Context) (*client.ClineClient, error) {
|
||||
defaultAddr := r.GetDefaultInstance()
|
||||
if defaultAddr == "" {
|
||||
return nil, fmt.Errorf("no default instance configured")
|
||||
}
|
||||
|
||||
return r.GetClient(ctx, defaultAddr)
|
||||
}
|
||||
|
||||
// ListInstances returns all registered instances directly from SQLite
|
||||
func (r *ClientRegistry) ListInstances() []*common.CoreInstanceInfo {
|
||||
if r.lockManager == nil {
|
||||
return []*common.CoreInstanceInfo{}
|
||||
}
|
||||
|
||||
// Use context with timeout for health checks
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
instances, err := r.lockManager.ListInstancesWithHealthCheck(ctx)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: Failed to list instances: %v\n", err)
|
||||
return []*common.CoreInstanceInfo{}
|
||||
}
|
||||
|
||||
return instances
|
||||
}
|
||||
|
||||
// HasInstanceAtAddress checks if an instance exists at the given address (delegates to SQLite)
|
||||
func (r *ClientRegistry) HasInstanceAtAddress(address string) bool {
|
||||
if r.lockManager == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
exists, err := r.lockManager.HasInstanceAtAddress(address)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: Failed to check instance existence: %v\n", err)
|
||||
return false
|
||||
}
|
||||
|
||||
return exists
|
||||
}
|
||||
|
||||
// CleanupStaleInstances removes stale instances using direct SQLite operations
|
||||
func (r *ClientRegistry) CleanupStaleInstances(ctx context.Context) error {
|
||||
if r.lockManager == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get all instances with health checks
|
||||
instances, err := r.lockManager.ListInstancesWithHealthCheck(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list instances for cleanup: %w", err)
|
||||
}
|
||||
|
||||
// Clean up all stale instances
|
||||
for _, instance := range instances {
|
||||
if instance.Status != grpc_health_v1.HealthCheckResponse_SERVING {
|
||||
// Try to gracefully shutdown the paired host process before cleanup
|
||||
|
||||
fmt.Printf("Attempting to shutdown dangling host service %s for stale cline core instance %s\n",
|
||||
instance.HostServiceAddress, instance.Address)
|
||||
r.tryShutdownHostProcess(instance.HostServiceAddress)
|
||||
|
||||
// Remove from SQLite database
|
||||
if err := r.lockManager.RemoveInstanceLock(instance.Address); err != nil {
|
||||
return fmt.Errorf("failed to remove stale instance %s: %w", instance.Address, err)
|
||||
}
|
||||
|
||||
fmt.Printf("Removed stale instance: %s\n", instance.Address)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// tryShutdownHostProcess attempts to gracefully shutdown a host process via RPC
|
||||
// Best effort, don't throw errors i guess
|
||||
func (r *ClientRegistry) tryShutdownHostProcess(hostServiceAddress string) {
|
||||
err := common.RetryOperation(3, 2*time.Second, func() error {
|
||||
// Create context with timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Create gRPC connection to host bridge
|
||||
conn, err := grpc.DialContext(ctx, hostServiceAddress,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpc.WithBlock())
|
||||
if err != nil {
|
||||
return fmt.Errorf("connection failed: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Create env service client and call shutdown
|
||||
envClient := host.NewEnvServiceClient(conn)
|
||||
_, err = envClient.Shutdown(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("RPC failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: Failed to request host bridge shutdown on port %s: %v\n", hostServiceAddress, err)
|
||||
} else {
|
||||
fmt.Printf("Host bridge shutdown requested successfully on port %s\n", hostServiceAddress)
|
||||
}
|
||||
}
|
||||
|
||||
// ListInstancesCleaned performs cleanup and returns instances with health checks
|
||||
func (r *ClientRegistry) ListInstancesCleaned(ctx context.Context) ([]*common.CoreInstanceInfo, error) {
|
||||
// 1. Clean up stale entries (best-effort)
|
||||
_ = r.CleanupStaleInstances(ctx)
|
||||
|
||||
// 2. Get all instances with real-time health checks
|
||||
instances := r.ListInstances()
|
||||
|
||||
// 3. Ensure default is set if instances exist
|
||||
if err := r.ensureDefaultInstance(instances); err != nil {
|
||||
fmt.Printf("Warning: Failed to ensure default instance: %v\n", err)
|
||||
}
|
||||
|
||||
return instances, nil
|
||||
}
|
||||
|
||||
// ensureDefaultInstance ensures a default instance is set if instances exist but no default is configured
|
||||
func (r *ClientRegistry) ensureDefaultInstance(instances []*common.CoreInstanceInfo) error {
|
||||
currentDefault := r.GetDefaultInstance()
|
||||
|
||||
// If we have no instances, clear any stale default and remove settings file
|
||||
if len(instances) == 0 {
|
||||
if currentDefault != "" {
|
||||
// Remove the settings file since no instances exist
|
||||
settingsPath := filepath.Join(r.configPath, common.SETTINGS_SUBFOLDER, "settings", "cli-default-instance.json")
|
||||
_ = os.Remove(settingsPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// If we have instances but no default, pick the first one
|
||||
if currentDefault == "" {
|
||||
return sqlite.SetDefaultInstance(r.configPath, instances[0].Address)
|
||||
}
|
||||
|
||||
// Validate current default still exists in the instances
|
||||
defaultExists := false
|
||||
for _, instance := range instances {
|
||||
if instance.Address == currentDefault {
|
||||
defaultExists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !defaultExists {
|
||||
// Current default doesn't exist, pick a new one from available instances
|
||||
return sqlite.SetDefaultInstance(r.configPath, instances[0].Address)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,354 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
)
|
||||
|
||||
// AskHandler handles ASK type messages
|
||||
type AskHandler struct {
|
||||
*BaseHandler
|
||||
}
|
||||
|
||||
// NewAskHandler creates a new ASK handler
|
||||
func NewAskHandler() *AskHandler {
|
||||
return &AskHandler{
|
||||
BaseHandler: NewBaseHandler("ask", PriorityHigh),
|
||||
}
|
||||
}
|
||||
|
||||
// CanHandle returns true if this is an ASK message
|
||||
func (h *AskHandler) CanHandle(msg *types.ClineMessage) bool {
|
||||
return msg.IsAsk()
|
||||
}
|
||||
|
||||
// Handle processes ASK messages
|
||||
func (h *AskHandler) Handle(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
timestamp := msg.GetTimestamp()
|
||||
|
||||
switch msg.Ask {
|
||||
case string(types.AskTypeFollowup):
|
||||
return h.handleFollowup(msg, dc, timestamp)
|
||||
case string(types.AskTypePlanModeRespond):
|
||||
return h.handlePlanModeRespond(msg, dc, timestamp)
|
||||
case string(types.AskTypeCommand):
|
||||
return h.handleCommand(msg, dc, timestamp)
|
||||
case string(types.AskTypeCommandOutput):
|
||||
return h.handleCommandOutput(msg, dc, timestamp)
|
||||
case string(types.AskTypeCompletionResult):
|
||||
return h.handleCompletionResult(msg, dc, timestamp)
|
||||
case string(types.AskTypeTool):
|
||||
return h.handleTool(msg, dc, timestamp)
|
||||
case string(types.AskTypeAPIReqFailed):
|
||||
return h.handleAPIReqFailed(msg, dc, timestamp)
|
||||
case string(types.AskTypeResumeTask):
|
||||
return h.handleResumeTask(msg, dc, timestamp)
|
||||
case string(types.AskTypeResumeCompletedTask):
|
||||
return h.handleResumeCompletedTask(msg, dc, timestamp)
|
||||
case string(types.AskTypeMistakeLimitReached):
|
||||
return h.handleMistakeLimitReached(msg, dc, timestamp)
|
||||
case string(types.AskTypeAutoApprovalMaxReached):
|
||||
return h.handleAutoApprovalMaxReached(msg, dc, timestamp)
|
||||
case string(types.AskTypeBrowserActionLaunch):
|
||||
return h.handleBrowserActionLaunch(msg, dc, timestamp)
|
||||
case string(types.AskTypeUseMcpServer):
|
||||
return h.handleUseMcpServer(msg, dc, timestamp)
|
||||
case string(types.AskTypeNewTask):
|
||||
return h.handleNewTask(msg, dc, timestamp)
|
||||
case string(types.AskTypeCondense):
|
||||
return h.handleCondense(msg, dc, timestamp)
|
||||
case string(types.AskTypeReportBug):
|
||||
return h.handleReportBug(msg, dc, timestamp)
|
||||
default:
|
||||
return h.handleDefault(msg, dc, timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
// handleFollowup handles followup questions
|
||||
func (h *AskHandler) handleFollowup(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
var question string
|
||||
var options []string
|
||||
|
||||
var askData types.AskData
|
||||
if err := json.Unmarshal([]byte(msg.Text), &askData); err == nil {
|
||||
question = askData.Question
|
||||
options = askData.Options
|
||||
} else {
|
||||
question = msg.Text
|
||||
}
|
||||
|
||||
if question == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := dc.Renderer.RenderMessage(timestamp, "QUESTION", question)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Display options if available
|
||||
if len(options) > 0 {
|
||||
fmt.Println("\nOptions:")
|
||||
for i, option := range options {
|
||||
fmt.Printf("%d. %s\n", i+1, option)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handlePlanModeRespond handles plan mode responses
|
||||
func (h *AskHandler) handlePlanModeRespond(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
var response string
|
||||
var options []string
|
||||
|
||||
// Try to parse as JSON
|
||||
type PlanModeResponse struct {
|
||||
Response string `json:"response"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
var planData PlanModeResponse
|
||||
if err := json.Unmarshal([]byte(msg.Text), &planData); err == nil {
|
||||
response = planData.Response
|
||||
options = planData.Options
|
||||
} else {
|
||||
response = msg.Text
|
||||
}
|
||||
|
||||
if response == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := dc.Renderer.RenderMessage(timestamp, "ASST PLAN", response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Display options if available
|
||||
if len(options) > 0 {
|
||||
fmt.Println("\nOptions:")
|
||||
for i, option := range options {
|
||||
fmt.Printf("%d. %s\n", i+1, option)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleCommand handles command execution requests
|
||||
func (h *AskHandler) handleCommand(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
command := msg.Text
|
||||
|
||||
// Check if this command was flagged despite auto-approval settings turned on for safe commands
|
||||
hasAutoApprovalConflict := strings.HasSuffix(command, "REQ_APP")
|
||||
if hasAutoApprovalConflict {
|
||||
command = strings.TrimSuffix(command, "REQ_APP")
|
||||
}
|
||||
|
||||
err := dc.Renderer.RenderMessage(timestamp, "TERMINAL", "Cline wants to execute this command:")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render handleCommand: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\n```shell\n%s\n```\n", strings.TrimSpace(command))
|
||||
|
||||
if hasAutoApprovalConflict {
|
||||
fmt.Printf("\nThe model has determined this command requires explicit approval.\n")
|
||||
} else {
|
||||
fmt.Printf("\nApproval required for this command.\n")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleCommandOutput handles command output requests
|
||||
func (h *AskHandler) handleCommandOutput(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
commandOutput := msg.Text
|
||||
|
||||
err := dc.Renderer.RenderMessage(timestamp, "TERMINAL", fmt.Sprintf("Current terminal output: %s", commandOutput))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render handleCommandOutput: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\nApprove to proceed while this command runs in the background.\n")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleCompletionResult handles completion result requests
|
||||
func (h *AskHandler) handleCompletionResult(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleTool handles tool execution requests
|
||||
func (h *AskHandler) handleTool(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
// Parse tool message
|
||||
var tool types.ToolMessage
|
||||
if err := json.Unmarshal([]byte(msg.Text), &tool); err != nil {
|
||||
// Fallback to simple display
|
||||
return dc.Renderer.RenderMessage(timestamp, "TOOL", msg.Text)
|
||||
}
|
||||
|
||||
return h.renderToolMessage(&tool, dc, timestamp)
|
||||
}
|
||||
|
||||
// renderToolMessage renders a tool message with appropriate formatting
|
||||
func (h *AskHandler) renderToolMessage(tool *types.ToolMessage, dc *DisplayContext, timestamp string) error {
|
||||
switch tool.Tool {
|
||||
case string(types.ToolTypeEditedExistingFile):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to edit file: %s", tool.Path))
|
||||
case string(types.ToolTypeNewFileCreated):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to create file: %s", tool.Path))
|
||||
case string(types.ToolTypeReadFile):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to read file: %s", tool.Path))
|
||||
case string(types.ToolTypeListFilesTopLevel):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to list files in: %s", tool.Path))
|
||||
case string(types.ToolTypeListFilesRecursive):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to recursively list files in: %s", tool.Path))
|
||||
case string(types.ToolTypeSearchFiles):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to search for '%s' in: %s", tool.Regex, tool.Path))
|
||||
case string(types.ToolTypeWebFetch):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to fetch URL: %s", tool.Path))
|
||||
case string(types.ToolTypeListCodeDefinitionNames):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to list code definitions for: %s", tool.Path))
|
||||
default:
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to use tool: %s", tool.Tool))
|
||||
}
|
||||
|
||||
// Skip content preview for readFile and webFetch tools
|
||||
if tool.Tool == string(types.ToolTypeReadFile) || tool.Tool == string(types.ToolTypeWebFetch) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Show content preview, truncating if necessary
|
||||
preview := tool.Content
|
||||
if preview != "" {
|
||||
preview = strings.TrimSpace(tool.Content)
|
||||
if len(preview) > 1000 {
|
||||
preview = preview[:1000] + "..."
|
||||
}
|
||||
|
||||
fmt.Printf("Preview: %s\n", preview)
|
||||
}
|
||||
|
||||
fmt.Printf("\nApproval required.\n")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleAPIReqFailed handles API request failures
|
||||
func (h *AskHandler) handleAPIReqFailed(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "ERROR", fmt.Sprintf("API Request Failed: %s. Approve to retry request.", msg.Text))
|
||||
}
|
||||
|
||||
// handleResumeTask handles resume task requests
|
||||
func (h *AskHandler) handleResumeTask(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "GEN INFO", "Resuming interrupted task.")
|
||||
}
|
||||
|
||||
// handleResumeCompletedTask handles resume completed task requests
|
||||
func (h *AskHandler) handleResumeCompletedTask(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "GEN INFO", "Resuming completed task.")
|
||||
}
|
||||
|
||||
// handleMistakeLimitReached handles mistake limit reached
|
||||
func (h *AskHandler) handleMistakeLimitReached(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "ERROR", fmt.Sprintf("Mistake Limit Reached: %s. Approval required.", msg.Text))
|
||||
}
|
||||
|
||||
// handleAutoApprovalMaxReached handles auto-approval max reached
|
||||
func (h *AskHandler) handleAutoApprovalMaxReached(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "WARNING", fmt.Sprintf("Auto-approval limit reached: %s. Approval required.", msg.Text))
|
||||
}
|
||||
|
||||
// handleBrowserActionLaunch handles browser action launch requests
|
||||
func (h *AskHandler) handleBrowserActionLaunch(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
url := strings.TrimSpace(msg.Text)
|
||||
return dc.Renderer.RenderMessage(timestamp, "BROWSER", fmt.Sprintf("Cline wants to launch browser and navigate to: %s. Approval required.", url))
|
||||
}
|
||||
|
||||
// handleUseMcpServer handles MCP server usage requests
|
||||
func (h *AskHandler) handleUseMcpServer(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
// Parse MCP server usage request
|
||||
type McpServerRequest struct {
|
||||
ServerName string `json:"serverName"`
|
||||
Type string `json:"type"`
|
||||
ToolName string `json:"toolName,omitempty"`
|
||||
Arguments string `json:"arguments,omitempty"`
|
||||
URI string `json:"uri,omitempty"`
|
||||
}
|
||||
|
||||
var mcpReq McpServerRequest
|
||||
if err := json.Unmarshal([]byte(msg.Text), &mcpReq); err != nil {
|
||||
return dc.Renderer.RenderMessage(timestamp, "MCP", msg.Text)
|
||||
}
|
||||
|
||||
var operation string
|
||||
if mcpReq.Type == "access_mcp_resource" {
|
||||
operation = "access a resource"
|
||||
} else {
|
||||
operation = fmt.Sprintf("use a tool (%s)", mcpReq.ToolName)
|
||||
if mcpReq.Arguments != "" {
|
||||
operation = fmt.Sprintf("%s with args (%s)", operation, mcpReq.Arguments)
|
||||
}
|
||||
}
|
||||
|
||||
return dc.Renderer.RenderMessage(timestamp, "MCP",
|
||||
fmt.Sprintf("Cline wants to %s on the %s MCP server", operation, mcpReq.ServerName))
|
||||
}
|
||||
|
||||
// handleNewTask handles new task creation requests
|
||||
func (h *AskHandler) handleNewTask(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "NEW TASK", fmt.Sprintf("Cline wants to start a new task: %s. Approval required.", msg.Text))
|
||||
}
|
||||
|
||||
// handleCondense handles conversation condensing requests
|
||||
func (h *AskHandler) handleCondense(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "CONDENSE", fmt.Sprintf("Cline wants to condense the conversation: %s. Approval required.", msg.Text))
|
||||
}
|
||||
|
||||
// handleReportBug handles bug report requests
|
||||
func (h *AskHandler) handleReportBug(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
var bugData struct {
|
||||
Title string `json:"title"`
|
||||
WhatHappened string `json:"what_happened"`
|
||||
StepsToReproduce string `json:"steps_to_reproduce"`
|
||||
APIRequestOutput string `json:"api_request_output"`
|
||||
AdditionalContext string `json:"additional_context"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(msg.Text), &bugData); err != nil {
|
||||
return dc.Renderer.RenderMessage(timestamp, "BUG REPORT", fmt.Sprintf("Cline wants to create a GitHub issue: %s. Approval required.", msg.Text))
|
||||
}
|
||||
|
||||
err := dc.Renderer.RenderMessage(timestamp, "BUG REPORT", "Cline wants to create a GitHub issue:")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render handleReportBug: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\n**Title**: %s\n", bugData.Title)
|
||||
fmt.Printf("**What Happened**: %s\n", bugData.WhatHappened)
|
||||
fmt.Printf("**Steps to Reproduce**: %s\n", bugData.StepsToReproduce)
|
||||
fmt.Printf("**API Request Output**: %s\n", bugData.APIRequestOutput)
|
||||
fmt.Printf("**Additional Context**: %s\n", bugData.AdditionalContext)
|
||||
fmt.Printf("\nApprove to create a GitHub issue.\n")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleDefault handles unknown ASK message types
|
||||
func (h *AskHandler) handleDefault(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "ASK", msg.Text)
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"github.com/cline/cli/pkg/cli/display"
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
)
|
||||
|
||||
// MessageHandler defines the interface for handling different message types
|
||||
type MessageHandler interface {
|
||||
// CanHandle returns true if this handler can process the given message
|
||||
CanHandle(msg *types.ClineMessage) bool
|
||||
|
||||
// Handle processes the message and renders it using the display context
|
||||
Handle(msg *types.ClineMessage, dc *DisplayContext) error
|
||||
|
||||
// GetPriority returns the priority of this handler (higher = more priority)
|
||||
GetPriority() int
|
||||
|
||||
// GetName returns a human-readable name for this handler
|
||||
GetName() string
|
||||
}
|
||||
|
||||
// DisplayContext provides context and utilities for message handlers
|
||||
type DisplayContext struct {
|
||||
State *types.ConversationState
|
||||
Renderer *display.Renderer
|
||||
IsLast bool
|
||||
IsPartial bool
|
||||
Verbose bool
|
||||
MessageIndex int
|
||||
}
|
||||
|
||||
// BaseHandler provides common functionality for message handlers
|
||||
type BaseHandler struct {
|
||||
name string
|
||||
priority int
|
||||
}
|
||||
|
||||
// NewBaseHandler creates a new base handler
|
||||
func NewBaseHandler(name string, priority int) *BaseHandler {
|
||||
return &BaseHandler{
|
||||
name: name,
|
||||
priority: priority,
|
||||
}
|
||||
}
|
||||
|
||||
// GetName returns the handler name
|
||||
func (h *BaseHandler) GetName() string {
|
||||
return h.name
|
||||
}
|
||||
|
||||
// GetPriority returns the handler priority
|
||||
func (h *BaseHandler) GetPriority() int {
|
||||
return h.priority
|
||||
}
|
||||
|
||||
// HandlerRegistry manages a collection of message handlers
|
||||
type HandlerRegistry struct {
|
||||
handlers []MessageHandler
|
||||
}
|
||||
|
||||
// NewHandlerRegistry creates a new handler registry
|
||||
func NewHandlerRegistry() *HandlerRegistry {
|
||||
return &HandlerRegistry{
|
||||
handlers: make([]MessageHandler, 0),
|
||||
}
|
||||
}
|
||||
|
||||
// Register adds a handler to the registry
|
||||
func (r *HandlerRegistry) Register(handler MessageHandler) {
|
||||
r.handlers = append(r.handlers, handler)
|
||||
|
||||
// Sort handlers by priority (highest first)
|
||||
for i := len(r.handlers) - 1; i > 0; i-- {
|
||||
if r.handlers[i].GetPriority() > r.handlers[i-1].GetPriority() {
|
||||
r.handlers[i], r.handlers[i-1] = r.handlers[i-1], r.handlers[i]
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle finds the appropriate handler and processes the message
|
||||
func (r *HandlerRegistry) Handle(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
for _, handler := range r.handlers {
|
||||
if handler.CanHandle(msg) {
|
||||
return handler.Handle(msg, dc)
|
||||
}
|
||||
}
|
||||
|
||||
// If no specific handler found, use default text handler
|
||||
return r.handleDefault(msg, dc)
|
||||
}
|
||||
|
||||
// handleDefault provides default handling for unrecognized messages
|
||||
func (r *HandlerRegistry) handleDefault(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
timestamp := msg.GetTimestamp()
|
||||
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
prefix := "RESPONSE:"
|
||||
|
||||
return dc.Renderer.RenderMessage(timestamp, prefix, msg.Text)
|
||||
}
|
||||
|
||||
// GetHandlers returns all registered handlers
|
||||
func (r *HandlerRegistry) GetHandlers() []MessageHandler {
|
||||
return r.handlers
|
||||
}
|
||||
|
||||
// GetHandlerByName finds a handler by name
|
||||
func (r *HandlerRegistry) GetHandlerByName(name string) MessageHandler {
|
||||
for _, handler := range r.handlers {
|
||||
if handler.GetName() == name {
|
||||
return handler
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandlerPriorities defines standard priority levels for handlers
|
||||
const (
|
||||
PriorityHigh = 100
|
||||
PriorityNormal = 50
|
||||
PriorityLow = 10
|
||||
)
|
||||
@@ -1,421 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
)
|
||||
|
||||
// SayHandler handles SAY type messages
|
||||
type SayHandler struct {
|
||||
*BaseHandler
|
||||
}
|
||||
|
||||
// NewSayHandler creates a new SAY handler
|
||||
func NewSayHandler() *SayHandler {
|
||||
return &SayHandler{
|
||||
BaseHandler: NewBaseHandler("say", PriorityNormal),
|
||||
}
|
||||
}
|
||||
|
||||
// CanHandle returns true if this is a SAY message
|
||||
func (h *SayHandler) CanHandle(msg *types.ClineMessage) bool {
|
||||
return msg.IsSay()
|
||||
}
|
||||
|
||||
// Handle processes SAY messages
|
||||
func (h *SayHandler) Handle(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
timestamp := msg.GetTimestamp()
|
||||
|
||||
switch msg.Say {
|
||||
case string(types.SayTypeTask):
|
||||
return h.handleTask(msg, dc, timestamp)
|
||||
case string(types.SayTypeError):
|
||||
return h.handleError(msg, dc, timestamp)
|
||||
case string(types.SayTypeAPIReqStarted):
|
||||
return h.handleAPIReqStarted(msg, dc, timestamp)
|
||||
case string(types.SayTypeAPIReqFinished):
|
||||
return h.handleAPIReqFinished(msg, dc, timestamp)
|
||||
case string(types.SayTypeText):
|
||||
return h.handleText(msg, dc, timestamp)
|
||||
case string(types.SayTypeReasoning):
|
||||
return h.handleReasoning(msg, dc, timestamp)
|
||||
case string(types.SayTypeCompletionResult):
|
||||
return h.handleCompletionResult(msg, dc, timestamp)
|
||||
case string(types.SayTypeUserFeedback):
|
||||
return h.handleUserFeedback(msg, dc, timestamp)
|
||||
case string(types.SayTypeUserFeedbackDiff):
|
||||
return h.handleUserFeedbackDiff(msg, dc, timestamp)
|
||||
case string(types.SayTypeAPIReqRetried):
|
||||
return h.handleAPIReqRetried(msg, dc, timestamp)
|
||||
case string(types.SayTypeCommand):
|
||||
return h.handleCommand(msg, dc, timestamp)
|
||||
case string(types.SayTypeCommandOutput):
|
||||
return h.handleCommandOutput(msg, dc, timestamp)
|
||||
case string(types.SayTypeTool):
|
||||
return h.handleTool(msg, dc, timestamp)
|
||||
case string(types.SayTypeShellIntegrationWarning):
|
||||
return h.handleShellIntegrationWarning(msg, dc, timestamp)
|
||||
case string(types.SayTypeBrowserActionLaunch):
|
||||
return h.handleBrowserActionLaunch(msg, dc, timestamp)
|
||||
case string(types.SayTypeBrowserAction):
|
||||
return h.handleBrowserAction(msg, dc, timestamp)
|
||||
case string(types.SayTypeBrowserActionResult):
|
||||
return h.handleBrowserActionResult(msg, dc, timestamp)
|
||||
case string(types.SayTypeMcpServerRequestStarted):
|
||||
return h.handleMcpServerRequestStarted(msg, dc, timestamp)
|
||||
case string(types.SayTypeMcpServerResponse):
|
||||
return h.handleMcpServerResponse(msg, dc, timestamp)
|
||||
case string(types.SayTypeMcpNotification):
|
||||
return h.handleMcpNotification(msg, dc, timestamp)
|
||||
case string(types.SayTypeUseMcpServer):
|
||||
return h.handleUseMcpServer(msg, dc, timestamp)
|
||||
case string(types.SayTypeDiffError):
|
||||
return h.handleDiffError(msg, dc, timestamp)
|
||||
case string(types.SayTypeDeletedAPIReqs):
|
||||
return h.handleDeletedAPIReqs(msg, dc, timestamp)
|
||||
case string(types.SayTypeClineignoreError):
|
||||
return h.handleClineignoreError(msg, dc, timestamp)
|
||||
case string(types.SayTypeCheckpointCreated):
|
||||
return h.handleCheckpointCreated(msg, dc, timestamp)
|
||||
case string(types.SayTypeLoadMcpDocumentation):
|
||||
return h.handleLoadMcpDocumentation(msg, dc, timestamp)
|
||||
case string(types.SayTypeInfo):
|
||||
return h.handleInfo(msg, dc, timestamp)
|
||||
case string(types.SayTypeTaskProgress):
|
||||
return h.handleTaskProgress(msg, dc, timestamp)
|
||||
default:
|
||||
return h.handleDefault(msg, dc, timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
// handleTask handles task messages
|
||||
func (h *SayHandler) handleTask(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleError handles error messages
|
||||
func (h *SayHandler) handleError(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "ERROR", msg.Text)
|
||||
}
|
||||
|
||||
// handleAPIReqStarted handles API request started messages
|
||||
func (h *SayHandler) handleAPIReqStarted(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
// Parse API request info
|
||||
apiInfo := types.APIRequestInfo{Cost: -1}
|
||||
if err := json.Unmarshal([]byte(msg.Text), &apiInfo); err != nil {
|
||||
return dc.Renderer.RenderMessage(timestamp, "API INFO", msg.Text)
|
||||
}
|
||||
|
||||
// Handle different API request states
|
||||
if apiInfo.CancelReason != "" {
|
||||
if apiInfo.CancelReason == "user_cancelled" {
|
||||
return dc.Renderer.RenderMessage(timestamp, "API INFO", "Request Cancelled")
|
||||
} else if apiInfo.CancelReason == "retries_exhausted" {
|
||||
return dc.Renderer.RenderMessage(timestamp, "API INFO", "Request Failed (Retries Exhausted)")
|
||||
}
|
||||
return dc.Renderer.RenderMessage(timestamp, "API INFO", "Streaming Failed")
|
||||
}
|
||||
|
||||
if apiInfo.Cost >= 0 {
|
||||
return dc.Renderer.RenderAPI(timestamp, "Request completed", &apiInfo)
|
||||
}
|
||||
|
||||
// Check for retry status
|
||||
if apiInfo.RetryStatus != nil {
|
||||
return dc.Renderer.RenderRetry(timestamp,
|
||||
apiInfo.RetryStatus.Attempt,
|
||||
apiInfo.RetryStatus.MaxAttempts,
|
||||
apiInfo.RetryStatus.DelaySec)
|
||||
}
|
||||
|
||||
return dc.Renderer.RenderAPI(timestamp, "Processing request", &apiInfo)
|
||||
}
|
||||
|
||||
// handleAPIReqFinished handles API request finished messages
|
||||
func (h *SayHandler) handleAPIReqFinished(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
// This message type is typically not displayed as it's handled by the started message
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleText handles regular text messages
|
||||
func (h *SayHandler) handleText(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Special case for the user's task input (no markdown rendering)
|
||||
if dc.MessageIndex == 0 {
|
||||
return dc.Renderer.RenderMessage(timestamp, "USER", msg.Text)
|
||||
}
|
||||
|
||||
// For rich mode, render markdown. For other modes, use plain text
|
||||
return dc.Renderer.RenderTextWithMarkdown(timestamp, msg.Text)
|
||||
}
|
||||
|
||||
// handleReasoning handles reasoning messages
|
||||
func (h *SayHandler) handleReasoning(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Render reasoning/thinking messages with markdown in rich mode
|
||||
return dc.Renderer.RenderTextWithMarkdown(timestamp, msg.Text)
|
||||
}
|
||||
|
||||
func (h *SayHandler) handleCompletionResult(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
text := msg.Text
|
||||
|
||||
if strings.HasSuffix(text, "HAS_CHANGES") {
|
||||
text = strings.TrimSuffix(text, "HAS_CHANGES")
|
||||
}
|
||||
|
||||
// Render completion results with markdown in rich mode
|
||||
return dc.Renderer.RenderTextWithMarkdown(timestamp, text)
|
||||
}
|
||||
|
||||
// handleUserFeedback handles user feedback messages
|
||||
func (h *SayHandler) handleUserFeedback(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
if msg.Text != "" {
|
||||
return dc.Renderer.RenderMessage(timestamp, "USER", msg.Text)
|
||||
} else {
|
||||
return dc.Renderer.RenderMessage(timestamp, "USER", "[Provided feedback without text]")
|
||||
}
|
||||
}
|
||||
|
||||
// handleUserFeedbackDiff handles user feedback diff messages
|
||||
func (h *SayHandler) handleUserFeedbackDiff(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
var toolMsg types.ToolMessage
|
||||
if err := json.Unmarshal([]byte(msg.Text), &toolMsg); err != nil {
|
||||
return dc.Renderer.RenderMessage(timestamp, "USER DIFF", msg.Text)
|
||||
}
|
||||
|
||||
message := fmt.Sprintf("User manually edited: %s\n\nDiff:\n%s",
|
||||
toolMsg.Path,
|
||||
toolMsg.Diff)
|
||||
|
||||
return dc.Renderer.RenderMessage(timestamp, "USER DIFF", message)
|
||||
}
|
||||
|
||||
// handleAPIReqRetried handles API request retry messages
|
||||
func (h *SayHandler) handleAPIReqRetried(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "API INFO", "Retrying request")
|
||||
}
|
||||
|
||||
// handleCommand handles command execution announcements
|
||||
func (h *SayHandler) handleCommand(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
command := strings.TrimSpace(msg.Text)
|
||||
|
||||
err := dc.Renderer.RenderMessage(timestamp, "TERMINAL", "Running command:")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render handleCommand: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\n```shell\n%s\n```\n", command)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleCommandOutput handles command output messages
|
||||
func (h *SayHandler) handleCommandOutput(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
commandOutput := msg.Text
|
||||
return dc.Renderer.RenderMessage(timestamp, "TERMINAL", fmt.Sprintf("Current terminal output: %s", commandOutput))
|
||||
}
|
||||
|
||||
func (h *SayHandler) handleTool(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
var tool types.ToolMessage
|
||||
if err := json.Unmarshal([]byte(msg.Text), &tool); err != nil {
|
||||
return dc.Renderer.RenderMessage(timestamp, "TOOL", msg.Text)
|
||||
}
|
||||
|
||||
return h.renderToolMessage(&tool, dc, timestamp)
|
||||
}
|
||||
|
||||
func (h *SayHandler) renderToolMessage(tool *types.ToolMessage, dc *DisplayContext, timestamp string) error {
|
||||
switch tool.Tool {
|
||||
case string(types.ToolTypeEditedExistingFile):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline edited file: %s", tool.Path))
|
||||
case string(types.ToolTypeNewFileCreated):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline created file: %s", tool.Path))
|
||||
case string(types.ToolTypeReadFile):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline read file: %s", tool.Path))
|
||||
case string(types.ToolTypeListFilesTopLevel):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline listed files in: %s", tool.Path))
|
||||
case string(types.ToolTypeListFilesRecursive):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline recursively listed files in: %s", tool.Path))
|
||||
case string(types.ToolTypeSearchFiles):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline searched for '%s' in: %s", tool.Regex, tool.Path))
|
||||
case string(types.ToolTypeWebFetch):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline fetched URL: %s", tool.Path))
|
||||
case string(types.ToolTypeListCodeDefinitionNames):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline listed code definitions for: %s", tool.Path))
|
||||
case string(types.ToolTypeSummarizeTask):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", "Cline condensed the conversation")
|
||||
default:
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline executed tool: %s", tool.Tool))
|
||||
}
|
||||
|
||||
// Skip content preview for readFile and webFetch tools
|
||||
if tool.Tool == string(types.ToolTypeReadFile) || tool.Tool == string(types.ToolTypeWebFetch) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Show content preview, truncating if necessary
|
||||
preview := tool.Content
|
||||
if preview != "" {
|
||||
preview = strings.TrimSpace(tool.Content)
|
||||
if len(preview) > 1000 {
|
||||
preview = preview[:1000] + "..."
|
||||
}
|
||||
fmt.Printf("Content: %s\n", preview)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleShellIntegrationWarning handles shell integration warning messages
|
||||
func (h *SayHandler) handleShellIntegrationWarning(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "WARNING", "Shell Integration Unavailable - Cline won't be able to view the command's output.")
|
||||
}
|
||||
|
||||
// handleBrowserActionLaunch handles browser action launch messages
|
||||
func (h *SayHandler) handleBrowserActionLaunch(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
url := msg.Text
|
||||
if url == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return dc.Renderer.RenderMessage(timestamp, "BROWSER", fmt.Sprintf("Launching browser at: %s", url))
|
||||
}
|
||||
|
||||
// handleBrowserAction handles browser action messages
|
||||
func (h *SayHandler) handleBrowserAction(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
type BrowserActionData struct {
|
||||
Action string `json:"action"`
|
||||
Coordinate string `json:"coordinate,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
var actionData BrowserActionData
|
||||
if err := json.Unmarshal([]byte(msg.Text), &actionData); err != nil {
|
||||
return dc.Renderer.RenderMessage(timestamp, "BROWSER", msg.Text)
|
||||
}
|
||||
|
||||
// Special handling for type action
|
||||
if actionData.Action == "type" && actionData.Text != "" {
|
||||
actionText := fmt.Sprintf("type '%s'", actionData.Text)
|
||||
return dc.Renderer.RenderMessage(timestamp, "BROWSER", fmt.Sprintf("Next action: %s", actionText))
|
||||
}
|
||||
|
||||
// Special handling for click action
|
||||
if actionData.Action == "click" && actionData.Coordinate != "" {
|
||||
actionText := fmt.Sprintf("click (%s)", actionData.Coordinate)
|
||||
return dc.Renderer.RenderMessage(timestamp, "BROWSER", fmt.Sprintf("Next action: %s", actionText))
|
||||
}
|
||||
|
||||
// Generic handling for all other actions
|
||||
return dc.Renderer.RenderMessage(timestamp, "BROWSER", fmt.Sprintf("Next action: %s", actionData.Action))
|
||||
}
|
||||
|
||||
// handleBrowserActionResult handles browser action result messages
|
||||
func (h *SayHandler) handleBrowserActionResult(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
type BrowserActionResult struct {
|
||||
Screenshot string `json:"screenshot,omitempty"`
|
||||
Logs string `json:"logs,omitempty"`
|
||||
CurrentUrl string `json:"currentUrl,omitempty"`
|
||||
CurrentMousePosition string `json:"currentMousePosition,omitempty"`
|
||||
}
|
||||
|
||||
var result BrowserActionResult
|
||||
if err := json.Unmarshal([]byte(msg.Text), &result); err != nil {
|
||||
return dc.Renderer.RenderMessage(timestamp, "BROWSER", "Action completed")
|
||||
}
|
||||
|
||||
// If we have logs, include them in the message
|
||||
if result.Logs != "" {
|
||||
return dc.Renderer.RenderMessage(timestamp, "BROWSER", fmt.Sprintf("Action completed with logs: '%s'", result.Logs))
|
||||
}
|
||||
|
||||
// Default case
|
||||
return dc.Renderer.RenderMessage(timestamp, "BROWSER", "Action completed")
|
||||
}
|
||||
|
||||
// handleMcpServerRequestStarted handles MCP server request started messages
|
||||
func (h *SayHandler) handleMcpServerRequestStarted(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "MCP", "Sending request to server")
|
||||
}
|
||||
|
||||
// handleMcpServerResponse handles MCP server response messages
|
||||
func (h *SayHandler) handleMcpServerResponse(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "MCP", fmt.Sprintf("Server response: %s", msg.Text))
|
||||
}
|
||||
|
||||
// handleMcpNotification handles MCP notification messages
|
||||
func (h *SayHandler) handleMcpNotification(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "MCP", fmt.Sprintf("Server notification: %s", msg.Text))
|
||||
}
|
||||
|
||||
// handleUseMcpServer handles MCP server usage messages
|
||||
func (h *SayHandler) handleUseMcpServer(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "MCP", "Server operation approved")
|
||||
}
|
||||
|
||||
// handleDiffError handles diff error messages
|
||||
func (h *SayHandler) handleDiffError(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "WARNING", "Diff Edit Failure - The model used an invalid diff edit format or used search patterns that don't match anything in the file.")
|
||||
}
|
||||
|
||||
// handleDeletedAPIReqs handles deleted API requests messages
|
||||
func (h *SayHandler) handleDeletedAPIReqs(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
// This message includes api metrics of deleted messages, which we do not log
|
||||
return dc.Renderer.RenderMessage(timestamp, "GEN INFO", "Checkpoint restored")
|
||||
}
|
||||
|
||||
// handleClineignoreError handles .clineignore error messages
|
||||
func (h *SayHandler) handleClineignoreError(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "WARNING", fmt.Sprintf("Access Denied - Cline tried to access %s which is blocked by the .clineignore file", msg.Text))
|
||||
}
|
||||
|
||||
// handleCheckpointCreated handles checkpoint created messages
|
||||
func (h *SayHandler) handleCheckpointCreated(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
message := fmt.Sprintf("Checkpoint created (ID: %d)", msg.Timestamp)
|
||||
return dc.Renderer.RenderMessage(timestamp, "GEN INFO", message)
|
||||
}
|
||||
|
||||
// handleLoadMcpDocumentation handles load MCP documentation messages
|
||||
func (h *SayHandler) handleLoadMcpDocumentation(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "GEN INFO", "Loading MCP documentation")
|
||||
}
|
||||
|
||||
// handleInfo handles info messages
|
||||
func (h *SayHandler) handleInfo(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleTaskProgress handles task progress messages
|
||||
func (h *SayHandler) handleTaskProgress(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return dc.Renderer.RenderMessage(timestamp, "PROGRESS", fmt.Sprintf("Task Checklist: %s", msg.Text))
|
||||
}
|
||||
|
||||
// handleDefault handles unknown SAY message types
|
||||
func (h *SayHandler) handleDefault(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "SAY", msg.Text)
|
||||
}
|
||||
@@ -1,388 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
"github.com/spf13/cobra"
|
||||
"google.golang.org/grpc/health/grpc_health_v1"
|
||||
)
|
||||
|
||||
func NewInstanceCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "instance",
|
||||
Aliases: []string{"i"},
|
||||
Short: "Manage Cline instances",
|
||||
Long: `List and manage multiple Cline instances similar to kubectl contexts.`,
|
||||
}
|
||||
|
||||
cmd.AddCommand(newInstanceListCommand())
|
||||
cmd.AddCommand(newInstanceUseCommand())
|
||||
cmd.AddCommand(newInstanceNewCommand())
|
||||
cmd.AddCommand(newInstanceKillCommand())
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newInstanceKillCommand() *cobra.Command {
|
||||
var killAll bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "kill <address>",
|
||||
Aliases: []string{"k"},
|
||||
Short: "Kill a Cline instance by address",
|
||||
Long: `Kill a running Cline instance and clean up its registry entry.`,
|
||||
Args: func(cmd *cobra.Command, args []string) error {
|
||||
if killAll && len(args) > 0 {
|
||||
return fmt.Errorf("cannot specify both --all flag and address argument")
|
||||
}
|
||||
if !killAll && len(args) != 1 {
|
||||
return fmt.Errorf("requires exactly one address argument when --all is not specified")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if global.Clients == nil {
|
||||
return fmt.Errorf("clients not initialized")
|
||||
}
|
||||
|
||||
ctx := cmd.Context()
|
||||
registry := global.Clients.GetRegistry()
|
||||
|
||||
if killAll {
|
||||
return killAllInstances(ctx, registry)
|
||||
} else {
|
||||
return killSingleInstance(ctx, registry, args[0])
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&killAll, "all", false, "kill all running instances")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func killSingleInstance(ctx context.Context, registry *global.ClientRegistry, address string) error {
|
||||
// Check if the instance exists in the registry
|
||||
_, err := registry.GetInstance(address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("instance %s not found in registry", address)
|
||||
}
|
||||
|
||||
fmt.Printf("Killing instance: %s\n", address)
|
||||
|
||||
// Get gRPC client and process info
|
||||
client, err := registry.GetClient(ctx, address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to instance %s: %w", address, err)
|
||||
}
|
||||
|
||||
processInfo, err := client.State.GetProcessInfo(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get process info for instance %s: %w", address, err)
|
||||
}
|
||||
|
||||
pid := int(processInfo.ProcessId)
|
||||
fmt.Printf("Terminating process PID %d...\n", pid)
|
||||
|
||||
// Kill the process
|
||||
if err := syscall.Kill(pid, syscall.SIGTERM); err != nil {
|
||||
return fmt.Errorf("failed to kill process %d: %w", pid, err)
|
||||
}
|
||||
|
||||
// Wait for the instance to remove itself from registry
|
||||
fmt.Printf("Waiting for instance to clean up registry entry...\n")
|
||||
for i := 0; i < 5; i++ {
|
||||
time.Sleep(1 * time.Second)
|
||||
if !registry.HasInstanceAtAddress(address) {
|
||||
fmt.Printf("Instance %s successfully killed and removed from registry.\n", address)
|
||||
|
||||
// Update default instance if needed
|
||||
instances, err := registry.ListInstancesCleaned(ctx)
|
||||
if err == nil && len(instances) > 0 {
|
||||
// ensureDefaultInstance logic will handle setting a new default
|
||||
defaultInstance := registry.GetDefaultInstance()
|
||||
if defaultInstance == address || defaultInstance == "" {
|
||||
if len(instances) > 0 {
|
||||
if err := registry.SetDefaultInstance(instances[0].Address); err == nil {
|
||||
fmt.Printf("Updated default instance to: %s\n", instances[0].Address)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("instance killed but failed to remove itself from registry within 5 seconds")
|
||||
}
|
||||
|
||||
func killAllInstances(ctx context.Context, registry *global.ClientRegistry) error {
|
||||
// Get all instances from registry
|
||||
instances, err := registry.ListInstancesCleaned(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list instances: %w", err)
|
||||
}
|
||||
|
||||
if len(instances) == 0 {
|
||||
fmt.Println("No Cline instances found to kill.")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("Killing %d instances...\n", len(instances))
|
||||
|
||||
var killResults []killResult
|
||||
|
||||
// Kill all instances
|
||||
for _, instance := range instances {
|
||||
result := killInstanceProcess(ctx, registry, instance.Address)
|
||||
killResults = append(killResults, result)
|
||||
|
||||
if result.err != nil {
|
||||
fmt.Printf("✗ Failed to kill %s: %v\n", instance.Address, result.err)
|
||||
} else if result.alreadyDead {
|
||||
fmt.Printf("⚠ Instance %s appears to be already dead\n", instance.Address)
|
||||
} else {
|
||||
fmt.Printf("✓ Killed %s (PID %d)\n", instance.Address, result.pid)
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for all instances to clean up their registry entries
|
||||
fmt.Printf("Waiting for instances to clean up registry entries...\n")
|
||||
|
||||
maxWaitTime := 10 // seconds
|
||||
for i := 0; i < maxWaitTime; i++ {
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
remainingInstances, err := registry.ListInstancesCleaned(ctx)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: failed to check registry status: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(remainingInstances) == 0 {
|
||||
fmt.Printf("✓ All instances successfully removed from registry.\n")
|
||||
break
|
||||
}
|
||||
|
||||
if i == maxWaitTime-1 {
|
||||
fmt.Printf("⚠ %d instances still in registry after %d seconds\n", len(remainingInstances), maxWaitTime)
|
||||
for _, remaining := range remainingInstances {
|
||||
fmt.Printf(" - %s\n", remaining.Address)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Print summary
|
||||
successful := 0
|
||||
failed := 0
|
||||
alreadyDead := 0
|
||||
|
||||
for _, result := range killResults {
|
||||
if result.err != nil {
|
||||
failed++
|
||||
} else if result.alreadyDead {
|
||||
alreadyDead++
|
||||
} else {
|
||||
successful++
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\nSummary: ")
|
||||
if successful > 0 {
|
||||
fmt.Printf("Successfully killed %d instances. ", successful)
|
||||
}
|
||||
if alreadyDead > 0 {
|
||||
fmt.Printf("%d were already dead. ", alreadyDead)
|
||||
}
|
||||
if failed > 0 {
|
||||
fmt.Printf("%d failures.", failed)
|
||||
return fmt.Errorf("failed to kill %d out of %d instances", failed, len(instances))
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type killResult struct {
|
||||
address string
|
||||
pid int
|
||||
alreadyDead bool
|
||||
err error
|
||||
}
|
||||
|
||||
func killInstanceProcess(ctx context.Context, registry *global.ClientRegistry, address string) killResult {
|
||||
// Get gRPC client and process info
|
||||
client, err := registry.GetClient(ctx, address)
|
||||
if err != nil {
|
||||
return killResult{address: address, alreadyDead: true, err: nil}
|
||||
}
|
||||
|
||||
processInfo, err := client.State.GetProcessInfo(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return killResult{address: address, alreadyDead: true, err: nil}
|
||||
}
|
||||
|
||||
pid := int(processInfo.ProcessId)
|
||||
|
||||
// Kill the process
|
||||
if err := syscall.Kill(pid, syscall.SIGTERM); err != nil {
|
||||
return killResult{address: address, pid: pid, err: err}
|
||||
}
|
||||
|
||||
return killResult{address: address, pid: pid, err: nil}
|
||||
}
|
||||
|
||||
func newInstanceListCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Aliases: []string{"l"},
|
||||
Short: "List all registered Cline instances",
|
||||
Long: `List all registered Cline instances with their status and connection details.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if global.Clients == nil {
|
||||
return fmt.Errorf("clients not initialized")
|
||||
}
|
||||
|
||||
ctx := cmd.Context()
|
||||
registry := global.Clients.GetRegistry()
|
||||
|
||||
// Load, cleanup stale local entries, and update health
|
||||
instances, err := registry.ListInstancesCleaned(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list instances: %w", err)
|
||||
}
|
||||
defaultInstance := registry.GetDefaultInstance()
|
||||
|
||||
if len(instances) == 0 {
|
||||
fmt.Println("No Cline instances found.")
|
||||
fmt.Println("Run 'cline instance new' to start a new instance, or 'cline task new \"...\"' to auto-start one.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Always output a table
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "ADDRESS\tSTATUS\tVERSION\tLAST SEEN\tPID\tDEFAULT")
|
||||
|
||||
for _, instance := range instances {
|
||||
isDefault := ""
|
||||
if instance.Address == defaultInstance {
|
||||
isDefault = "*"
|
||||
}
|
||||
|
||||
lastSeen := instance.LastSeen.Format("15:04:05")
|
||||
if time.Since(instance.LastSeen) > 24*time.Hour {
|
||||
lastSeen = instance.LastSeen.Format("2006-01-02")
|
||||
}
|
||||
|
||||
// Get PID via RPC if instance is healthy
|
||||
pid := "N/A"
|
||||
if instance.Status == grpc_health_v1.HealthCheckResponse_SERVING {
|
||||
if client, err := registry.GetClient(ctx, instance.Address); err == nil {
|
||||
if processInfo, err := client.State.GetProcessInfo(ctx, &cline.EmptyRequest{}); err == nil {
|
||||
pid = fmt.Sprintf("%d", processInfo.ProcessId)
|
||||
// Update version from RPC if available
|
||||
if processInfo.Version != nil && *processInfo.Version != "" && *processInfo.Version != "unknown" {
|
||||
instance.Version = *processInfo.Version
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
|
||||
instance.Address,
|
||||
instance.Status,
|
||||
instance.Version,
|
||||
lastSeen,
|
||||
pid,
|
||||
isDefault,
|
||||
)
|
||||
}
|
||||
|
||||
w.Flush()
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newInstanceUseCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "use <address>",
|
||||
Aliases: []string{"u"},
|
||||
Short: "Set the default Cline instance",
|
||||
Long: `Set the default Cline instance to use for subsequent commands.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
address := args[0]
|
||||
|
||||
if global.Clients == nil {
|
||||
return fmt.Errorf("clients not initialized")
|
||||
}
|
||||
|
||||
registry := global.Clients.GetRegistry()
|
||||
|
||||
// Verify the instance exists
|
||||
_, err := registry.GetInstance(address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("instance %s not found. Run 'cline instance list' to see available instances", address)
|
||||
}
|
||||
|
||||
// Set as default
|
||||
if err := registry.SetDefaultInstance(address); err != nil {
|
||||
return fmt.Errorf("failed to set default instance: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Switched to instance: %s\n", address)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newInstanceNewCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "new",
|
||||
Aliases: []string{"n"},
|
||||
Short: "Create a new Cline instance",
|
||||
Long: `Create a new Cline instance with automatically assigned ports.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
if global.Clients == nil {
|
||||
return fmt.Errorf("clients not initialized")
|
||||
}
|
||||
|
||||
fmt.Println("Starting new Cline instance...")
|
||||
|
||||
instance, err := global.Clients.StartNewInstance(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start instance: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Successfully started new instance:\n")
|
||||
fmt.Printf(" Address: %s\n", instance.Address)
|
||||
fmt.Printf(" Core Port: %d\n", instance.CorePort())
|
||||
fmt.Printf(" Host Bridge Port: %d\n", instance.HostPort())
|
||||
|
||||
// Check if this is now the default instance
|
||||
registry := global.Clients.GetRegistry()
|
||||
if registry.GetDefaultInstance() == instance.Address {
|
||||
fmt.Printf(" Status: Default instance\n")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -1,366 +0,0 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/common"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"google.golang.org/grpc/health/grpc_health_v1"
|
||||
)
|
||||
|
||||
// normalizeAddressVariants returns address variants to try when querying SQLite.
|
||||
// Handles localhost/127.0.0.1 equivalence by returning both forms.
|
||||
func normalizeAddressVariants(address string) []string {
|
||||
variants := []string{address}
|
||||
|
||||
// Extract host and port
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return variants
|
||||
}
|
||||
|
||||
// Add the alternate form for localhost/127.0.0.1
|
||||
if host == "localhost" {
|
||||
variants = append(variants, net.JoinHostPort("127.0.0.1", port))
|
||||
} else if host == "127.0.0.1" {
|
||||
variants = append(variants, net.JoinHostPort("localhost", port))
|
||||
}
|
||||
|
||||
return variants
|
||||
}
|
||||
|
||||
// LockManager provides access to the SQLite locks database
|
||||
type LockManager struct {
|
||||
dbPath string
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewLockManager creates a new lock manager
|
||||
func NewLockManager(clineDir string) (*LockManager, error) {
|
||||
dbPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "locks.db")
|
||||
|
||||
// Ensure the directory exists (for future DB creation by cline-core)
|
||||
dbDir := filepath.Dir(dbPath)
|
||||
if err := os.MkdirAll(dbDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create database directory: %w", err)
|
||||
}
|
||||
|
||||
// Check if database exists
|
||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||
// Database doesn't exist - return manager with nil db
|
||||
// All methods already handle this gracefully!
|
||||
return &LockManager{dbPath: dbPath, db: nil}, nil
|
||||
}
|
||||
|
||||
// Database exists - open it normally (no schema creation)
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
// If we can't open existing database, return nil db manager
|
||||
return &LockManager{dbPath: dbPath, db: nil}, nil
|
||||
}
|
||||
|
||||
// Test the connection
|
||||
if err := db.Ping(); err != nil {
|
||||
db.Close()
|
||||
// If connection fails, return nil db manager
|
||||
return &LockManager{dbPath: dbPath, db: nil}, nil
|
||||
}
|
||||
|
||||
return &LockManager{
|
||||
dbPath: dbPath,
|
||||
db: db,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ensureConnection attempts to establish a database connection if one doesn't exist
|
||||
func (lm *LockManager) ensureConnection() error {
|
||||
// If we already have a connection, we're done
|
||||
if lm.db != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if database exists now (created by cline-core)
|
||||
if _, err := os.Stat(lm.dbPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("database not available")
|
||||
}
|
||||
|
||||
// Database exists, try to connect
|
||||
db, err := sql.Open("sqlite3", lm.dbPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
db.Close()
|
||||
return fmt.Errorf("database connection failed: %w", err)
|
||||
}
|
||||
|
||||
// Success! Update our connection permanently
|
||||
lm.db = db
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the database connection
|
||||
func (lm *LockManager) Close() error {
|
||||
if lm.db != nil {
|
||||
return lm.db.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetInstanceLocks returns all instance locks
|
||||
func (lm *LockManager) GetInstanceLocks() ([]common.LockRow, error) {
|
||||
if err := lm.ensureConnection(); err != nil {
|
||||
return []common.LockRow{}, nil
|
||||
}
|
||||
|
||||
query := common.SelectInstanceLocksSQL
|
||||
|
||||
rows, err := lm.db.Query(query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query instance locks: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var locks []common.LockRow
|
||||
for rows.Next() {
|
||||
var lock common.LockRow
|
||||
err := rows.Scan(&lock.ID, &lock.HeldBy, &lock.LockType, &lock.LockTarget, &lock.LockedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan lock row: %w", err)
|
||||
}
|
||||
locks = append(locks, lock)
|
||||
}
|
||||
|
||||
return locks, nil
|
||||
}
|
||||
|
||||
// RemoveInstanceLock removes an instance lock by address
|
||||
func (lm *LockManager) RemoveInstanceLock(address string) error {
|
||||
if err := lm.ensureConnection(); err != nil {
|
||||
return nil // Gracefully handle missing database for cleanup operations
|
||||
}
|
||||
|
||||
query := common.DeleteInstanceLockSQL
|
||||
_, err := lm.db.Exec(query, address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove instance lock: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasInstanceAtAddress checks if an instance exists at the given address
|
||||
func (lm *LockManager) HasInstanceAtAddress(address string) (bool, error) {
|
||||
if err := lm.ensureConnection(); err != nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
query := common.CountInstanceLockSQL
|
||||
var count int
|
||||
err := lm.db.QueryRow(query, address).Scan(&count)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to check instance existence: %w", err)
|
||||
}
|
||||
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// GetInstanceInfo returns instance information directly from SQLite.
|
||||
// Handles localhost/127.0.0.1 equivalence by trying both variants.
|
||||
func (lm *LockManager) GetInstanceInfo(address string) (*common.CoreInstanceInfo, error) {
|
||||
if err := lm.ensureConnection(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query := common.SelectInstanceLockByHolderSQL
|
||||
variants := normalizeAddressVariants(address)
|
||||
|
||||
var heldBy, lockTarget string
|
||||
var lockedAt int64
|
||||
var lastErr error
|
||||
|
||||
// Try each address variant (e.g., localhost:50607 and 127.0.0.1:50607)
|
||||
for _, variant := range variants {
|
||||
err := lm.db.QueryRow(query, variant).Scan(&heldBy, &lockTarget, &lockedAt)
|
||||
if err == nil {
|
||||
// Found it!
|
||||
return &common.CoreInstanceInfo{
|
||||
Address: heldBy,
|
||||
HostServiceAddress: lockTarget,
|
||||
Status: grpc_health_v1.HealthCheckResponse_UNKNOWN,
|
||||
LastSeen: time.Unix(lockedAt/1000, 0),
|
||||
}, nil
|
||||
}
|
||||
if err != sql.ErrNoRows {
|
||||
// Real error (not just "not found"), save it
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
|
||||
// None of the variants were found
|
||||
if lastErr != nil {
|
||||
return nil, fmt.Errorf("failed to query instance: %w", lastErr)
|
||||
}
|
||||
return nil, fmt.Errorf("instance %s not found", address)
|
||||
}
|
||||
|
||||
// ListInstancesWithHealthCheck returns all instances with real-time health checks
|
||||
func (lm *LockManager) ListInstancesWithHealthCheck(ctx context.Context) ([]*common.CoreInstanceInfo, error) {
|
||||
if err := lm.ensureConnection(); err != nil {
|
||||
return []*common.CoreInstanceInfo{}, nil
|
||||
}
|
||||
|
||||
// Get all instance locks
|
||||
locks, err := lm.GetInstanceLocks()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get instance locks: %w", err)
|
||||
}
|
||||
|
||||
var instances []*common.CoreInstanceInfo
|
||||
|
||||
for _, lock := range locks {
|
||||
// Create instance info using actual SQLite data
|
||||
status, err := common.PerformHealthCheck(ctx, lock.HeldBy)
|
||||
if status != grpc_health_v1.HealthCheckResponse_SERVING || err != nil {
|
||||
time.Sleep(1 * time.Second)
|
||||
status, err = common.PerformHealthCheck(ctx, lock.HeldBy)
|
||||
}
|
||||
|
||||
info := &common.CoreInstanceInfo{
|
||||
Address: lock.HeldBy,
|
||||
HostServiceAddress: lock.LockTarget,
|
||||
Status: status,
|
||||
LastSeen: time.Unix(lock.LockedAt/1000, 0),
|
||||
}
|
||||
|
||||
instances = append(instances, info)
|
||||
}
|
||||
|
||||
return instances, nil
|
||||
}
|
||||
|
||||
// GetDefaultInstance reads the default instance from the settings file
|
||||
func GetDefaultInstance(clineDir string) (string, error) {
|
||||
settingsPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "settings", "cli-default-instance.json")
|
||||
|
||||
data, err := os.ReadFile(settingsPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", nil
|
||||
}
|
||||
return "", fmt.Errorf("failed to read default instance file: %w", err)
|
||||
}
|
||||
|
||||
var defaultInstance common.DefaultCoreInstance
|
||||
if err := json.Unmarshal(data, &defaultInstance); err != nil {
|
||||
return "", fmt.Errorf("failed to parse default instance JSON: %w", err)
|
||||
}
|
||||
|
||||
if defaultInstance.Address == "" {
|
||||
return "", fmt.Errorf("default instance not set in settings file")
|
||||
}
|
||||
|
||||
return defaultInstance.Address, nil
|
||||
}
|
||||
|
||||
// SetDefaultInstance writes the default instance to the settings file with proper locking
|
||||
func SetDefaultInstance(clineDir, address string) error {
|
||||
// Create lock manager for this operation
|
||||
lockManager, err := NewLockManager(clineDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Warning: SQLite unavailable, writing without lock: %v\n", err)
|
||||
}
|
||||
defer lockManager.Close()
|
||||
|
||||
settingsPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "settings", "cli-default-instance.json")
|
||||
|
||||
// Generate a unique identifier for this CLI process
|
||||
heldBy := fmt.Sprintf("cli-process-%d", os.Getpid())
|
||||
|
||||
// Use file lock for the write operation
|
||||
return lockManager.WithFileLock(settingsPath, heldBy, func() error {
|
||||
return writeDefaultInstanceJSONToDisk(clineDir, address)
|
||||
})
|
||||
}
|
||||
|
||||
func writeDefaultInstanceJSONToDisk(clineDir, address string) error {
|
||||
settingsDir := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "settings")
|
||||
if err := os.MkdirAll(settingsDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create settings directory: %w", err)
|
||||
}
|
||||
|
||||
settingsPath := filepath.Join(settingsDir, "cli-default-instance.json")
|
||||
|
||||
payload := common.DefaultCoreInstance{
|
||||
Address: address,
|
||||
LastUpdated: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(payload, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal default instance JSON: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(settingsPath, data, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write default instance file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AcquireFileLock attempts to acquire a file lock
|
||||
func (lm *LockManager) AcquireFileLock(filePath, heldBy string) error {
|
||||
if err := lm.ensureConnection(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now().Unix() * 1000 // Convert to milliseconds
|
||||
|
||||
query := common.InsertFileLockSQL
|
||||
|
||||
_, err := lm.db.Exec(query, heldBy, filePath, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to acquire file lock for %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReleaseFileLock releases a file lock
|
||||
func (lm *LockManager) ReleaseFileLock(filePath, heldBy string) error {
|
||||
if lm.db == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
query := common.DeleteFileLockSQL
|
||||
|
||||
_, err := lm.db.Exec(query, heldBy, filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to release file lock for %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// WithFileLock executes a function while holding a file lock
|
||||
func (lm *LockManager) WithFileLock(filePath, heldBy string, fn func() error) error {
|
||||
if err := lm.AcquireFileLock(filePath, heldBy); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if releaseErr := lm.ReleaseFileLock(filePath, heldBy); releaseErr != nil {
|
||||
fmt.Printf("Warning: Failed to release file lock for %s: %v\n", filePath, releaseErr)
|
||||
}
|
||||
}()
|
||||
|
||||
return fn()
|
||||
}
|
||||
@@ -1,501 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/task"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewTaskCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "task",
|
||||
Aliases: []string{"t"},
|
||||
Short: "Manage Cline tasks",
|
||||
Long: `Create, monitor, and manage Cline AI tasks.`,
|
||||
}
|
||||
|
||||
cmd.AddCommand(newTaskNewCommand())
|
||||
cmd.AddCommand(newTaskCancelCommand())
|
||||
cmd.AddCommand(newTaskFollowCommand())
|
||||
cmd.AddCommand(NewTaskSendCommand())
|
||||
cmd.AddCommand(newTaskViewCommand())
|
||||
cmd.AddCommand(newTaskListCommand())
|
||||
cmd.AddCommand(newTaskResumeCommand())
|
||||
cmd.AddCommand(newTaskRestoreCommand())
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
var taskManager *task.Manager
|
||||
|
||||
func ensureTaskManager(ctx context.Context, address string) error {
|
||||
if taskManager == nil || (address != "" && taskManager.GetCurrentInstance() != address) {
|
||||
var err error
|
||||
var instanceAddress string
|
||||
|
||||
if address != "" {
|
||||
// Ensure instance exists at the specified address
|
||||
if err := ensureInstanceAtAddress(ctx, address); err != nil {
|
||||
return fmt.Errorf("failed to ensure instance at address %s: %w", address, err)
|
||||
}
|
||||
taskManager, err = task.NewManagerForAddress(ctx, address)
|
||||
instanceAddress = address
|
||||
} else {
|
||||
// Ensure default instance exists
|
||||
if err := ensureDefaultInstance(ctx); err != nil {
|
||||
return fmt.Errorf("failed to ensure default instance: %w", err)
|
||||
}
|
||||
taskManager, err = task.NewManagerForDefault(ctx)
|
||||
if err == nil {
|
||||
instanceAddress = taskManager.GetCurrentInstance()
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create task manager: %w", err)
|
||||
}
|
||||
|
||||
// Always set the instance we're using as the default
|
||||
registry := global.Clients.GetRegistry()
|
||||
if err := registry.SetDefaultInstance(instanceAddress); err != nil {
|
||||
// Log warning but don't fail - this is not critical
|
||||
fmt.Printf("Warning: failed to set default instance: %v\n", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureInstanceAtAddress ensures an instance exists at the given address
|
||||
func ensureInstanceAtAddress(ctx context.Context, address string) error {
|
||||
if global.Clients == nil {
|
||||
return fmt.Errorf("global clients not initialized")
|
||||
}
|
||||
return global.Clients.EnsureInstanceAtAddress(ctx, address)
|
||||
}
|
||||
|
||||
// ensureDefaultInstance ensures a default instance exists
|
||||
func ensureDefaultInstance(ctx context.Context) error {
|
||||
if global.Clients == nil {
|
||||
return fmt.Errorf("global clients not initialized")
|
||||
}
|
||||
|
||||
// Check if we have any instances in the registry
|
||||
registry := global.Clients.GetRegistry()
|
||||
if registry.GetDefaultInstance() == "" {
|
||||
// No default instance, start a new one
|
||||
instance, err := global.Clients.StartNewInstance(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start new default instance: %w", err)
|
||||
}
|
||||
|
||||
// Set the new instance as default
|
||||
if err := registry.SetDefaultInstance(instance.Address); err != nil {
|
||||
return fmt.Errorf("failed to set default instance: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func newTaskNewCommand() *cobra.Command {
|
||||
var (
|
||||
images []string
|
||||
files []string
|
||||
wait bool
|
||||
workspaces []string
|
||||
address string
|
||||
mode string
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "new <prompt>",
|
||||
Aliases: []string{"n"},
|
||||
Short: "Create a new task",
|
||||
Long: `Create a new Cline task with the specified prompt. If no Cline instance exists at the specified address, a new one will be started automatically.`,
|
||||
Args: cobra.MinimumNArgs(0),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
// Get content from both args and stdin
|
||||
prompt, err := getContentFromStdinAndArgs(args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read prompt: %w", err)
|
||||
}
|
||||
|
||||
// Validate that prompt is passed in call
|
||||
if prompt == "" {
|
||||
return fmt.Errorf("prompt required: provide as argument or pipe via stdin")
|
||||
}
|
||||
|
||||
// Ensure task manager is initialized
|
||||
if err := ensureTaskManager(ctx, address); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set mode if provided
|
||||
if mode != "" {
|
||||
if err := taskManager.SetMode(ctx, mode, nil, nil, nil); err != nil {
|
||||
return fmt.Errorf("failed to set mode: %w", err)
|
||||
}
|
||||
fmt.Printf("Mode set to: %s\n", mode)
|
||||
}
|
||||
|
||||
// Create the task
|
||||
taskID, err := taskManager.CreateTask(ctx, prompt, images, files, workspaces)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create task: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Task created successfully with ID: %s\n", taskID)
|
||||
fmt.Printf("Using instance: %s\n", taskManager.GetCurrentInstance())
|
||||
|
||||
// Wait for completion if requested
|
||||
if wait {
|
||||
fmt.Println("Following task conversation...")
|
||||
return taskManager.FollowConversation(ctx)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringSliceVarP(&images, "image", "i", nil, "attach image files")
|
||||
cmd.Flags().StringSliceVarP(&files, "file", "f", nil, "attach files")
|
||||
cmd.Flags().BoolVar(&wait, "wait", false, "wait for task completion")
|
||||
cmd.Flags().StringSliceVarP(&workspaces, "workdir", "w", nil, "workdir directory paths")
|
||||
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
|
||||
cmd.Flags().StringVarP(&mode, "mode", "m", "", "mode (act|plan)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newTaskCancelCommand() *cobra.Command {
|
||||
var address string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "cancel",
|
||||
Aliases: []string{"c"},
|
||||
Short: "Cancel the current task",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
if err := ensureTaskManager(ctx, address); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := taskManager.CancelTask(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("Task cancelled successfully")
|
||||
fmt.Printf("Instance: %s\n", taskManager.GetCurrentInstance())
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func NewTaskSendCommand() *cobra.Command {
|
||||
var (
|
||||
images []string
|
||||
files []string
|
||||
address string
|
||||
mode string
|
||||
approve string
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "send [message]",
|
||||
Aliases: []string{"s"},
|
||||
Short: "Send a followup message to the current task and/or update mode/approve",
|
||||
Long: `Send a followup message to continue the conversation with the current task and/or update mode/approve.`,
|
||||
Args: cobra.MinimumNArgs(0),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
// Get content from both args and stdin
|
||||
message, err := getContentFromStdinAndArgs(args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read message: %w", err)
|
||||
}
|
||||
|
||||
if message == "" && len(images) == 0 && len(files) == 0 && mode == "" && approve == "" {
|
||||
return fmt.Errorf("content (message, files, images) required unless using --mode or --approve flags")
|
||||
}
|
||||
|
||||
if approve != "" && approve != "true" && approve != "false" {
|
||||
return fmt.Errorf("--approve must be 'true' or 'false'")
|
||||
}
|
||||
|
||||
if approve != "" && mode != "" {
|
||||
return fmt.Errorf("cannot use --approve and --mode together")
|
||||
}
|
||||
|
||||
// Ensure task manager is initialized
|
||||
if err := ensureTaskManager(ctx, address); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sendDisabled, err := taskManager.CheckSendDisabled(ctx)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if message can be sent: %w", err)
|
||||
}
|
||||
|
||||
if sendDisabled {
|
||||
fmt.Println("Cannot send message: task is currently busy")
|
||||
return nil
|
||||
}
|
||||
|
||||
if mode != "" {
|
||||
if err := taskManager.SetModeAndSendMessage(ctx, mode, message, images, files); err != nil {
|
||||
return fmt.Errorf("failed to set mode and send message: %w", err)
|
||||
}
|
||||
fmt.Printf("Mode set to %s and message sent successfully.\n", mode)
|
||||
|
||||
} else {
|
||||
if err := taskManager.SendMessage(ctx, message, images, files, approve); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Message sent successfully.\n")
|
||||
}
|
||||
|
||||
fmt.Printf("Instance: %s\n", taskManager.GetCurrentInstance())
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringSliceVarP(&images, "image", "i", nil, "attach image files")
|
||||
cmd.Flags().StringSliceVarP(&files, "file", "f", nil, "attach files")
|
||||
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
|
||||
cmd.Flags().StringVarP(&mode, "mode", "m", "", "mode (act|plan)")
|
||||
cmd.Flags().StringVarP(&approve, "approve", "a", "", "approve (true) or deny (false) pending request")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newTaskFollowCommand() *cobra.Command {
|
||||
var address string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "follow",
|
||||
Aliases: []string{"f"},
|
||||
Short: "Follow current task conversation in real-time",
|
||||
Long: `Follow the current task conversation, displaying new messages as they arrive in real-time.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
if err := ensureTaskManager(ctx, address); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Using instance: %s\n", taskManager.GetCurrentInstance())
|
||||
|
||||
return taskManager.FollowConversation(ctx)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newTaskViewCommand() *cobra.Command {
|
||||
var (
|
||||
current bool
|
||||
summary bool
|
||||
address string
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "view",
|
||||
Aliases: []string{"v"},
|
||||
Short: "View task conversation",
|
||||
Long: `Output conversation until next completion, with options for current state or summary only.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
if err := ensureTaskManager(ctx, address); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Using instance: %s\n", taskManager.GetCurrentInstance())
|
||||
|
||||
if current {
|
||||
return taskManager.ShowConversation(ctx)
|
||||
} else if summary {
|
||||
return taskManager.GatherFinalSummary(ctx)
|
||||
} else {
|
||||
return taskManager.FollowConversationUntilCompletion(ctx)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVarP(¤t, "current", "c", false, "output current conversation without following")
|
||||
cmd.Flags().BoolVarP(&summary, "summary", "s", false, "outputs only the completion summary")
|
||||
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newTaskListCommand() *cobra.Command {
|
||||
var address string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Aliases: []string{"l"},
|
||||
Short: "List recent task history",
|
||||
Long: `Display recent tasks from task history.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
// Ensure task manager is initialized
|
||||
if err := ensureTaskManager(ctx, address); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Using instance: %s\n", taskManager.GetCurrentInstance())
|
||||
|
||||
return taskManager.ListTasks(ctx)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newTaskResumeCommand() *cobra.Command {
|
||||
var address string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "resume <task-id>",
|
||||
Aliases: []string{"r"},
|
||||
Short: "Resume a task by ID",
|
||||
Long: `Resume an existing task by ID.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
taskID := args[0]
|
||||
|
||||
// Ensure task manager is initialized
|
||||
if err := ensureTaskManager(ctx, address); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Using instance: %s\n", taskManager.GetCurrentInstance())
|
||||
|
||||
return taskManager.ResumeTask(ctx, taskID)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newTaskRestoreCommand() *cobra.Command {
|
||||
var (
|
||||
restoreType string
|
||||
address string
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "restore <checkpoint-id>",
|
||||
Short: "Restore task to a specific checkpoint",
|
||||
Long: `Restore the current task to a specific checkpoint by checkpoint ID (timestamp) and by type.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
checkpointID := args[0]
|
||||
|
||||
// Convert checkpoint ID string to int64
|
||||
id, err := strconv.ParseInt(checkpointID, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid checkpoint ID '%s': must be a valid number", checkpointID)
|
||||
}
|
||||
|
||||
validTypes := []string{"task", "workspace", "taskAndWorkspace"}
|
||||
if !slices.Contains(validTypes, restoreType) {
|
||||
return fmt.Errorf("invalid restore type '%s': must be one of [task, workspace, taskAndWorkspace]", restoreType)
|
||||
}
|
||||
|
||||
// Ensure task manager is initialized
|
||||
if err := ensureTaskManager(ctx, address); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate checkpoint exists before attempting restore
|
||||
if err := taskManager.ValidateCheckpointExists(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Using instance: %s\n", taskManager.GetCurrentInstance())
|
||||
fmt.Printf("Restoring to checkpoint %d (type: %s)\n", id, restoreType)
|
||||
|
||||
if err := taskManager.RestoreCheckpoint(ctx, id, restoreType); err != nil {
|
||||
return fmt.Errorf("failed to restore checkpoint: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Checkpoint restored successfully")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&restoreType, "type", "t", "task", "Restore type (task, workspace, taskAndWorkspace)")
|
||||
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// getContentFromStdinAndArgs reads content from both command line args and stdin, and combines them
|
||||
func getContentFromStdinAndArgs(args []string) (string, error) {
|
||||
var content strings.Builder
|
||||
|
||||
// Add command line args first (if any)
|
||||
if len(args) > 0 {
|
||||
content.WriteString(strings.Join(args, " "))
|
||||
}
|
||||
|
||||
// Check if stdin has data
|
||||
stat, err := os.Stdin.Stat()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to stat stdin: %w", err)
|
||||
}
|
||||
|
||||
// Check if data is being piped to stdin
|
||||
if (stat.Mode() & os.ModeCharDevice) == 0 {
|
||||
stdinBytes, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read from stdin: %w", err)
|
||||
}
|
||||
|
||||
stdinContent := strings.TrimSpace(string(stdinBytes))
|
||||
if stdinContent != "" {
|
||||
if content.Len() > 0 {
|
||||
content.WriteString(" ")
|
||||
}
|
||||
content.WriteString(stdinContent)
|
||||
}
|
||||
}
|
||||
|
||||
return content.String(), nil
|
||||
}
|
||||
|
||||
// CleanupTaskManager cleans up the task manager resources
|
||||
func CleanupTaskManager() {
|
||||
if taskManager != nil {
|
||||
taskManager.Cleanup()
|
||||
}
|
||||
}
|
||||
@@ -1,994 +0,0 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/display"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/handlers"
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
"github.com/cline/grpc-go/client"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
// Manager handles task execution and message display
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
client *client.ClineClient
|
||||
clientAddress string
|
||||
state *types.ConversationState
|
||||
renderer *display.Renderer
|
||||
streamingDisplay *display.StreamingDisplay
|
||||
handlerRegistry *handlers.HandlerRegistry
|
||||
}
|
||||
|
||||
// NewManager creates a new task manager
|
||||
func NewManager(client *client.ClineClient) *Manager {
|
||||
state := types.NewConversationState()
|
||||
renderer := display.NewRenderer()
|
||||
streamingDisplay := display.NewStreamingDisplay(state, renderer)
|
||||
|
||||
// Create handler registry and register handlers
|
||||
registry := handlers.NewHandlerRegistry()
|
||||
registry.Register(handlers.NewAskHandler())
|
||||
registry.Register(handlers.NewSayHandler())
|
||||
|
||||
return &Manager{
|
||||
client: client,
|
||||
clientAddress: "", // Will be set when client is provided
|
||||
state: state,
|
||||
renderer: renderer,
|
||||
streamingDisplay: streamingDisplay,
|
||||
handlerRegistry: registry,
|
||||
}
|
||||
}
|
||||
|
||||
// NewManagerForAddress creates a new task manager for a specific instance address
|
||||
func NewManagerForAddress(ctx context.Context, address string) (*Manager, error) {
|
||||
client, err := global.GetClientForAddress(ctx, address)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get client for address %s: %w", address, err)
|
||||
}
|
||||
|
||||
manager := NewManager(client)
|
||||
manager.clientAddress = address
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
// NewManagerForDefault creates a new task manager using the default instance
|
||||
func NewManagerForDefault(ctx context.Context) (*Manager, error) {
|
||||
client, err := global.GetDefaultClient(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get default client: %w", err)
|
||||
}
|
||||
|
||||
manager := NewManager(client)
|
||||
|
||||
// Get the default instance address
|
||||
if global.Clients != nil {
|
||||
manager.clientAddress = global.Clients.GetRegistry().GetDefaultInstance()
|
||||
}
|
||||
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
// SwitchToInstance switches the manager to use a different Cline instance
|
||||
func (m *Manager) SwitchToInstance(ctx context.Context, address string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Get client for the new address
|
||||
newClient, err := global.GetClientForAddress(ctx, address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get client for address %s: %w", address, err)
|
||||
}
|
||||
|
||||
// Update the client and address
|
||||
m.client = newClient
|
||||
m.clientAddress = address
|
||||
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("Switched to instance: %s", address)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCurrentInstance returns the address of the current instance
|
||||
func (m *Manager) GetCurrentInstance() string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.clientAddress
|
||||
}
|
||||
|
||||
// CreateTask creates a new task
|
||||
func (m *Manager) CreateTask(ctx context.Context, prompt string, images, files []string, workspacePaths []string) (string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("Creating task: %s", prompt)
|
||||
if len(files) > 0 {
|
||||
m.renderer.RenderDebug("Files: %v", files)
|
||||
}
|
||||
if len(images) > 0 {
|
||||
m.renderer.RenderDebug("Images: %v", images)
|
||||
}
|
||||
if len(workspacePaths) > 0 {
|
||||
m.renderer.RenderDebug("Workspaces: %v", workspacePaths)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if there's an active task and cancel it first
|
||||
if err := m.cancelExistingTaskIfNeeded(ctx); err != nil {
|
||||
return "", fmt.Errorf("failed to cancel existing task: %w", err)
|
||||
}
|
||||
|
||||
// Create task request
|
||||
req := &cline.NewTaskRequest{
|
||||
Text: prompt,
|
||||
Images: images,
|
||||
Files: files,
|
||||
}
|
||||
|
||||
resp, err := m.client.Task.NewTask(ctx, req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create task: %w", err)
|
||||
}
|
||||
|
||||
taskID := resp.Value
|
||||
|
||||
return taskID, nil
|
||||
}
|
||||
|
||||
// cancelExistingTaskIfNeeded checks if there's an active task and cancels it
|
||||
func (m *Manager) cancelExistingTaskIfNeeded(ctx context.Context) error {
|
||||
// Try to get the current state to check if there's an active task
|
||||
state, err := m.client.State.GetLatestState(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
// If we can't get state, assume no active task and continue
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("Could not get state to check for active task: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Properly parse the state to check if there's actually an active task
|
||||
if state.StateJson != "" {
|
||||
var stateData types.ExtensionState
|
||||
if err := json.Unmarshal([]byte(state.StateJson), &stateData); err != nil {
|
||||
// If we can't parse state, assume no active task
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("Could not parse state JSON: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if there's actually an active task
|
||||
if stateData.CurrentTaskItem != nil && stateData.CurrentTaskItem.Id != "" {
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("Found active task %s, cancelling...", stateData.CurrentTaskItem.Id)
|
||||
}
|
||||
|
||||
// Cancel the existing task
|
||||
_, err := m.client.Task.CancelTask(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("Cancel task returned error: %v", err)
|
||||
}
|
||||
} else {
|
||||
fmt.Println("Cancelled existing task to start new one")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateCheckpointExists checks if a checkpoint ID is valid
|
||||
func (m *Manager) ValidateCheckpointExists(ctx context.Context, checkpointID int64) error {
|
||||
// Get current state
|
||||
state, err := m.client.State.GetLatestState(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get state: %w", err)
|
||||
}
|
||||
|
||||
// Extract messages
|
||||
messages, err := m.extractMessagesFromState(state.StateJson)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to extract messages: %w", err)
|
||||
}
|
||||
|
||||
// Find and validate the checkpoint message
|
||||
for _, msg := range messages {
|
||||
if msg.Timestamp == checkpointID {
|
||||
if msg.Say != string(types.SayTypeCheckpointCreated) {
|
||||
return fmt.Errorf("timestamp %d is not a checkpoint (type: %s)", checkpointID, msg.Type)
|
||||
}
|
||||
return nil // Valid checkpoint
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("checkpoint ID %d not found in task history", checkpointID)
|
||||
}
|
||||
|
||||
// CheckSendDisabled determines if we can send a message to the current task
|
||||
// We duplicate the logic from buttonConfig::getButtonConfig
|
||||
func (m *Manager) CheckSendDisabled(ctx context.Context) (bool, error) {
|
||||
state, err := m.client.State.GetLatestState(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to get latest state: %w", err)
|
||||
}
|
||||
|
||||
messages, err := m.extractMessagesFromState(state.StateJson)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to extract messages: %w", err)
|
||||
}
|
||||
|
||||
if len(messages) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Use final message to perform validation
|
||||
lastMessage := messages[len(messages)-1]
|
||||
|
||||
// Error types which we allow sending on
|
||||
errorTypes := []string{
|
||||
string(types.AskTypeAPIReqFailed), // "api_req_failed"
|
||||
string(types.AskTypeMistakeLimitReached), // "mistake_limit_reached"
|
||||
string(types.AskTypeAutoApprovalMaxReached), // "auto_approval_max_req_reached"
|
||||
}
|
||||
|
||||
isError := false
|
||||
|
||||
// Check if message is an error type
|
||||
if lastMessage.Type == types.MessageTypeAsk {
|
||||
for _, errType := range errorTypes {
|
||||
if lastMessage.Ask == errType {
|
||||
isError = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Streaming and error check
|
||||
if lastMessage.Partial && !isError {
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("Send disabled: task is streaming and non-error")
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// All ask messages allow sending
|
||||
if lastMessage.Type == types.MessageTypeAsk {
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("Send enabled: ask message")
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Technically unnecessary but implements getButtonConfig 1-1
|
||||
if lastMessage.Type == types.MessageTypeSay && lastMessage.Say == string(types.SayTypeAPIReqStarted) {
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("Send disabled: API request is active")
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("Send disabled: default fallback")
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// SendMessage sends a followup message to the current task
|
||||
func (m *Manager) SendMessage(ctx context.Context, message string, images, files []string, approve string) error {
|
||||
responseType := "messageResponse"
|
||||
|
||||
if approve == "true" {
|
||||
responseType = "yesButtonClicked"
|
||||
}
|
||||
|
||||
if approve == "false" {
|
||||
responseType = "noButtonClicked"
|
||||
}
|
||||
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("Sending message: %s", message)
|
||||
if len(files) > 0 {
|
||||
m.renderer.RenderDebug("Files: %v", files)
|
||||
}
|
||||
if len(images) > 0 {
|
||||
m.renderer.RenderDebug("Images: %v", images)
|
||||
}
|
||||
}
|
||||
|
||||
// Send the followup message using AskResponse
|
||||
req := &cline.AskResponseRequest{
|
||||
ResponseType: responseType,
|
||||
Text: message,
|
||||
Images: images,
|
||||
Files: files,
|
||||
}
|
||||
|
||||
_, err := m.client.Task.AskResponse(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send message: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetMode sets the Plan/Act mode for the current Cline instance and optionally sends message
|
||||
func (m *Manager) SetMode(ctx context.Context, mode string, message *string, images, files []string) error {
|
||||
if mode != "act" && mode != "plan" {
|
||||
return fmt.Errorf("invalid mode '%s': must be 'act' or 'plan'", mode)
|
||||
}
|
||||
|
||||
var protoMode cline.PlanActMode
|
||||
if mode == "plan" {
|
||||
protoMode = cline.PlanActMode_PLAN
|
||||
} else {
|
||||
protoMode = cline.PlanActMode_ACT
|
||||
}
|
||||
|
||||
req := &cline.TogglePlanActModeRequest{
|
||||
Metadata: &cline.Metadata{},
|
||||
Mode: protoMode,
|
||||
}
|
||||
|
||||
if message != nil {
|
||||
req.ChatContent = &cline.ChatContent{
|
||||
Message: message,
|
||||
Images: images,
|
||||
Files: files,
|
||||
}
|
||||
}
|
||||
|
||||
_, err := m.client.State.TogglePlanActModeProto(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set mode to '%s': %w", mode, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetModeAndSendMessage sets the mode and sends a message in one operation
|
||||
// Handles task restoration internally if the mode switch cancels the current task
|
||||
func (m *Manager) SetModeAndSendMessage(ctx context.Context, mode, message string, images, files []string) error {
|
||||
if mode != "act" && mode != "plan" {
|
||||
return fmt.Errorf("invalid mode '%s': must be 'act' or 'plan'", mode)
|
||||
}
|
||||
|
||||
taskId, err := m.getCurrentTaskId(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current task ID: %w", err)
|
||||
}
|
||||
fmt.Printf("Current task ID: %s\n", taskId)
|
||||
|
||||
var protoMode cline.PlanActMode
|
||||
if mode == "plan" {
|
||||
protoMode = cline.PlanActMode_PLAN
|
||||
} else {
|
||||
protoMode = cline.PlanActMode_ACT
|
||||
}
|
||||
|
||||
req := &cline.TogglePlanActModeRequest{
|
||||
Metadata: &cline.Metadata{},
|
||||
Mode: protoMode,
|
||||
ChatContent: &cline.ChatContent{
|
||||
Message: &message,
|
||||
Images: images,
|
||||
Files: files,
|
||||
},
|
||||
}
|
||||
|
||||
result, err := m.client.State.TogglePlanActModeProto(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set mode to '%s': %w", mode, err)
|
||||
}
|
||||
|
||||
taskPreserved := result.Value
|
||||
|
||||
if taskPreserved {
|
||||
fmt.Printf("Message sent as part of mode change\n")
|
||||
return nil
|
||||
} else {
|
||||
if message != "" || len(images) > 0 || len(files) > 0 {
|
||||
fmt.Printf("Task was cancelled, restoring task ID: %s\n", taskId)
|
||||
|
||||
err = m.ReinitExistingTaskFromId(ctx, taskId)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to restore task: %w", err)
|
||||
}
|
||||
fmt.Printf("Task restored successfully\n")
|
||||
|
||||
// Hardcoded sleep should be replaced with a way to fetch whether task is ready algorithmically
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
err = m.SendMessage(ctx, message, images, files, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to send message: %w", err)
|
||||
}
|
||||
fmt.Printf("Message sent to restored task\n")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getCurrentTaskId extracts the current task ID from the server state
|
||||
func (m *Manager) getCurrentTaskId(ctx context.Context) (string, error) {
|
||||
// Get the latest state
|
||||
state, err := m.client.State.GetLatestState(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get state: %w", err)
|
||||
}
|
||||
|
||||
// Parse the server state JSON
|
||||
var stateData types.ExtensionState
|
||||
if err := json.Unmarshal([]byte(state.StateJson), &stateData); err != nil {
|
||||
return "", fmt.Errorf("failed to parse state JSON: %w", err)
|
||||
}
|
||||
|
||||
// Extract current task ID
|
||||
if stateData.CurrentTaskItem != nil && stateData.CurrentTaskItem.Id != "" {
|
||||
return stateData.CurrentTaskItem.Id, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no current task found in state")
|
||||
}
|
||||
|
||||
// ReinitExistingTaskFromId reinitializes an existing task from the given task ID
|
||||
func (m *Manager) ReinitExistingTaskFromId(ctx context.Context, taskId string) error {
|
||||
req := &cline.StringRequest{Value: taskId}
|
||||
resp, err := m.client.Task.ShowTaskWithId(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to reinitialize task %s: %w", taskId, err)
|
||||
}
|
||||
|
||||
fmt.Printf("Successfully reinitialized task: %s (ID: %s)\n", taskId, resp.Id)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResumeTask resumes an existing task by ID
|
||||
func (m *Manager) ResumeTask(ctx context.Context, taskID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("Resuming task: %s", taskID)
|
||||
}
|
||||
|
||||
// This call handles cancellation of any active task
|
||||
if err := m.ReinitExistingTaskFromId(ctx, taskID); err != nil {
|
||||
return fmt.Errorf("failed to resume task %s: %w", taskID, err)
|
||||
}
|
||||
|
||||
fmt.Printf("Task %s resumed successfully\n", taskID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RestoreCheckpoint restores the task to a specific checkpoint
|
||||
func (m *Manager) RestoreCheckpoint(ctx context.Context, checkpointID int64, restoreType string) error {
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("Restoring checkpoint: %d (type: %s)", checkpointID, restoreType)
|
||||
}
|
||||
|
||||
// Create the checkpoint restore request
|
||||
req := &cline.CheckpointRestoreRequest{
|
||||
Metadata: &cline.Metadata{},
|
||||
Number: checkpointID,
|
||||
RestoreType: restoreType,
|
||||
}
|
||||
|
||||
_, err := m.client.Checkpoints.CheckpointRestore(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to restore checkpoint %d: %w", checkpointID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelTask cancels the current task
|
||||
func (m *Manager) CancelTask(ctx context.Context) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
_, err := m.client.Task.CancelTask(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to cancel task: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListTasks retrieves and displays task history
|
||||
func (m *Manager) ListTasks(ctx context.Context) error {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
req := &cline.GetTaskHistoryRequest{
|
||||
FavoritesOnly: false,
|
||||
SearchQuery: "",
|
||||
SortBy: "oldest",
|
||||
CurrentWorkspaceOnly: false,
|
||||
}
|
||||
|
||||
resp, err := m.client.Task.GetTaskHistory(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get task history: %w", err)
|
||||
}
|
||||
|
||||
if len(resp.Tasks) == 0 {
|
||||
fmt.Println("No task history found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
return m.renderer.RenderTaskList(resp.Tasks)
|
||||
}
|
||||
|
||||
// GatherFinalSummary attempts to gather the latest completion_result output and display it
|
||||
func (m *Manager) GatherFinalSummary(ctx context.Context) error {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
state, err := m.client.State.GetLatestState(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get state: %w", err)
|
||||
}
|
||||
|
||||
messages, err := m.extractMessagesFromState(state.StateJson)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to extract messages: %w", err)
|
||||
}
|
||||
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
msg := messages[i]
|
||||
|
||||
// Check if this is a completion result SAY message
|
||||
if msg.IsSay() && msg.Say == string(types.SayTypeCompletionResult) {
|
||||
return m.displayMessage(msg, false, false, i)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ShowConversation displays the current conversation
|
||||
func (m *Manager) ShowConversation(ctx context.Context) error {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
// Get the latest state which contains messages
|
||||
state, err := m.client.State.GetLatestState(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get state: %w", err)
|
||||
}
|
||||
|
||||
// Parse the state JSON to extract messages
|
||||
messages, err := m.extractMessagesFromState(state.StateJson)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to extract messages: %w", err)
|
||||
}
|
||||
|
||||
if len(messages) == 0 {
|
||||
fmt.Println("No conversation history found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Display messages
|
||||
for i, msg := range messages {
|
||||
m.displayMessage(msg, false, false, i)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) FollowConversation(ctx context.Context) error {
|
||||
fmt.Println("Following task conversation... (Press Ctrl+C to exit)")
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
// Create stream coordinator
|
||||
coordinator := NewStreamCoordinator()
|
||||
|
||||
// Load history first
|
||||
totalMessageCount, err := m.loadAndDisplayRecentHistory(ctx)
|
||||
if err != nil {
|
||||
m.renderer.RenderDebug("Warning: Failed to load conversation history: %v", err)
|
||||
totalMessageCount = 0
|
||||
}
|
||||
coordinator.SetConversationTurnStartIndex(totalMessageCount)
|
||||
|
||||
fmt.Println("\n--- Live updates ---")
|
||||
|
||||
// Start both streams concurrently
|
||||
errChan := make(chan error, 2)
|
||||
|
||||
if global.Config.OutputFormat == "json" {
|
||||
go m.handleStateStream(ctx, coordinator, errChan, nil)
|
||||
} else {
|
||||
go m.handleStateStream(ctx, coordinator, errChan, nil)
|
||||
go m.handlePartialMessageStream(ctx, coordinator, errChan)
|
||||
}
|
||||
|
||||
// Wait for either stream to error or context cancellation
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case err := <-errChan:
|
||||
cancel()
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// FollowConversationUntilCompletion streams conversation updates until task completion
|
||||
func (m *Manager) FollowConversationUntilCompletion(ctx context.Context) error {
|
||||
fmt.Println("Streaming conversation until completion... (Press Ctrl+C to exit)")
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
// Create stream coordinator
|
||||
coordinator := NewStreamCoordinator()
|
||||
|
||||
// Get current message count without displaying history
|
||||
totalMessageCount, err := m.getCurrentMessageCount(ctx)
|
||||
if err != nil {
|
||||
m.renderer.RenderDebug("Warning: Failed to get current message count: %v", err)
|
||||
totalMessageCount = 0
|
||||
}
|
||||
coordinator.SetConversationTurnStartIndex(totalMessageCount)
|
||||
|
||||
// Start both streams concurrently
|
||||
errChan := make(chan error, 2)
|
||||
completionChan := make(chan bool, 1)
|
||||
|
||||
if global.Config.OutputFormat == "json" {
|
||||
go m.handleStateStream(ctx, coordinator, errChan, completionChan)
|
||||
} else {
|
||||
go m.handleStateStream(ctx, coordinator, errChan, completionChan)
|
||||
go m.handlePartialMessageStream(ctx, coordinator, errChan)
|
||||
}
|
||||
|
||||
// Wait for completion, error, or context cancellation
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-completionChan:
|
||||
cancel()
|
||||
return nil
|
||||
case err := <-errChan:
|
||||
cancel()
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// handleStateStream handles the SubscribeToState stream
|
||||
func (m *Manager) handleStateStream(ctx context.Context, coordinator *StreamCoordinator, errChan chan error, completionChan chan bool) {
|
||||
stateStream, err := m.client.State.SubscribeToState(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("failed to subscribe to state: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
stateUpdate, err := stateStream.Recv()
|
||||
if err != nil {
|
||||
m.renderer.RenderDebug("State stream receive error: %v", err)
|
||||
errChan <- fmt.Errorf("failed to receive state update: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
var pErr error
|
||||
|
||||
if global.Config.OutputFormat == "json" {
|
||||
pErr = m.processStateUpdateJsonMode(stateUpdate, coordinator, completionChan)
|
||||
} else {
|
||||
pErr = m.processStateUpdate(stateUpdate, coordinator, completionChan)
|
||||
}
|
||||
|
||||
if pErr != nil {
|
||||
m.renderer.RenderDebug("State processing error: %v", pErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) processStateUpdateJsonMode(stateUpdate *cline.State, coordinator *StreamCoordinator, completionChan chan bool) error {
|
||||
messages, err := m.extractMessagesFromState(stateUpdate.StateJson)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Process messages from current conversation turn onwards
|
||||
startIndex := coordinator.GetConversationTurnStartIndex()
|
||||
|
||||
var foundCompletion bool
|
||||
var displayedUsage bool
|
||||
|
||||
for i := startIndex; i < len(messages); i++ {
|
||||
msg := messages[i]
|
||||
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("State message %d: type=%s, say=%s", i, msg.Type, msg.Say)
|
||||
}
|
||||
|
||||
// Exit after we've seen a task completion & printed out the usage info
|
||||
if msg.Say == string(types.SayTypeCompletionResult) {
|
||||
foundCompletion = true
|
||||
}
|
||||
|
||||
// Determine if message is ready to be displayed now
|
||||
shouldDisplay := true
|
||||
|
||||
switch {
|
||||
case msg.Say == string(types.SayTypeAPIReqStarted):
|
||||
shouldDisplay = false
|
||||
apiInfo := types.APIRequestInfo{Cost: -1}
|
||||
if err := json.Unmarshal([]byte(msg.Text), &apiInfo); err == nil && apiInfo.Cost >= 0 {
|
||||
shouldDisplay = true
|
||||
displayedUsage = true
|
||||
}
|
||||
}
|
||||
|
||||
// Skip if message is partial, except for a specific edge case
|
||||
if msg.Partial {
|
||||
// Exception: display if type=say, text="", say="text"
|
||||
if msg.IsSay() && msg.Text == "" && msg.Say == string(types.SayTypeText) {
|
||||
shouldDisplay = true
|
||||
} else {
|
||||
shouldDisplay = false
|
||||
}
|
||||
}
|
||||
|
||||
// Display valid messages, exit as soon as we hit a non-valid message
|
||||
if shouldDisplay {
|
||||
coordinator.CompleteTurn(i + 1) // Mark the message as complete as soon as we print it
|
||||
m.displayMessage(msg, false, false, i)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// We only want to exit after we've displayed the usage, for the case of seeing completion result
|
||||
if completionChan != nil && foundCompletion && displayedUsage {
|
||||
completionChan <- true
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// processStateUpdate processes state updates and supports logic for handling task competion markers
|
||||
func (m *Manager) processStateUpdate(stateUpdate *cline.State, coordinator *StreamCoordinator, completionChan chan bool) error {
|
||||
messages, err := m.extractMessagesFromState(stateUpdate.StateJson)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Process messages from current conversation turn onwards
|
||||
startIndex := coordinator.GetConversationTurnStartIndex()
|
||||
|
||||
var foundCompletion bool
|
||||
var displayedUsage bool
|
||||
|
||||
for i := startIndex; i < len(messages); i++ {
|
||||
msg := messages[i]
|
||||
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("State message %d: type=%s, say=%s", i, msg.Type, msg.Say)
|
||||
}
|
||||
|
||||
// Exit after we've seen a task completion & printed out the usage info
|
||||
if msg.Say == string(types.SayTypeCompletionResult) {
|
||||
foundCompletion = true
|
||||
}
|
||||
|
||||
// Currently handling a subset of message types for displaying
|
||||
switch {
|
||||
case msg.Say == string(types.SayTypeUserFeedback):
|
||||
if !coordinator.IsProcessedInCurrentTurn("user_msg") {
|
||||
m.displayMessage(msg, false, false, i)
|
||||
coordinator.MarkProcessedInCurrentTurn("user_msg")
|
||||
}
|
||||
|
||||
case msg.Say == string(types.SayTypeCheckpointCreated):
|
||||
if !coordinator.IsProcessedInCurrentTurn("checkpoint") {
|
||||
m.displayMessage(msg, false, false, i)
|
||||
coordinator.MarkProcessedInCurrentTurn("checkpoint")
|
||||
}
|
||||
|
||||
case msg.Say == string(types.SayTypeAPIReqStarted):
|
||||
apiInfo := types.APIRequestInfo{Cost: -1}
|
||||
if err := json.Unmarshal([]byte(msg.Text), &apiInfo); err == nil && apiInfo.Cost >= 0 {
|
||||
fmt.Println() // adds a separator between cline message and usage message
|
||||
m.displayMessage(msg, false, false, i)
|
||||
coordinator.CompleteTurn(len(messages))
|
||||
displayedUsage = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// We only want to exit after we've displayed the usage, for the case of seeing completion result
|
||||
if completionChan != nil && foundCompletion && displayedUsage {
|
||||
completionChan <- true
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handlePartialMessageStream handles the SubscribeToPartialMessage stream for streaming assistant text
|
||||
func (m *Manager) handlePartialMessageStream(ctx context.Context, coordinator *StreamCoordinator, errChan chan error) {
|
||||
partialStream, err := m.client.Ui.SubscribeToPartialMessage(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("failed to subscribe to partial messages: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
protoMsg, err := partialStream.Recv()
|
||||
if err != nil {
|
||||
m.renderer.RenderDebug("Partial stream receive error: %v", err)
|
||||
errChan <- fmt.Errorf("failed to receive partial message: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert proto message to our Message struct
|
||||
msg := types.ConvertProtoToMessage(protoMsg)
|
||||
|
||||
// Debug: Log received message (always show for debugging)
|
||||
m.renderer.RenderDebug("Received streaming message: type=%s, partial=%v, text_len=%d",
|
||||
msg.Type, msg.Partial, len(msg.Text))
|
||||
|
||||
// Handle the message with streaming support for de-dupping
|
||||
if err := m.handleStreamingMessage(msg); err != nil {
|
||||
m.renderer.RenderDebug("Error handling streaming message: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleStreamingMessage handles a streaming message
|
||||
func (m *Manager) handleStreamingMessage(msg *types.ClineMessage) error {
|
||||
// Debug: Always log what we're processing
|
||||
m.renderer.RenderDebug("Processing message: timestamp=%d, partial=%v, type=%s, text_preview=%s",
|
||||
msg.Timestamp, msg.Partial, msg.Type, m.truncateText(msg.Text, 50))
|
||||
|
||||
// Use streaming display which handles deduplication internally
|
||||
if err := m.streamingDisplay.HandlePartialMessage(msg); err != nil {
|
||||
m.renderer.RenderDebug("Streaming display failed, using fallback: %v", err)
|
||||
// Fallback to regular display
|
||||
return m.displayMessage(msg, true, false, -1)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// truncateText truncates text for debug display
|
||||
func (m *Manager) truncateText(text string, maxLen int) string {
|
||||
if len(text) <= maxLen {
|
||||
return text
|
||||
}
|
||||
return text[:maxLen] + "..."
|
||||
}
|
||||
|
||||
// displayMessage displays a single message using the handler system
|
||||
func (m *Manager) displayMessage(msg *types.ClineMessage, isLast, isPartial bool, messageIndex int) error {
|
||||
if global.Config.OutputFormat == "json" {
|
||||
return m.outputMessageAsJSON(msg)
|
||||
} else {
|
||||
dc := &handlers.DisplayContext{
|
||||
State: m.state,
|
||||
Renderer: m.renderer,
|
||||
IsLast: isLast,
|
||||
IsPartial: isPartial,
|
||||
MessageIndex: messageIndex,
|
||||
}
|
||||
|
||||
return m.handlerRegistry.Handle(msg, dc)
|
||||
}
|
||||
}
|
||||
|
||||
// outputMessageAsJSON prints a single cline message as json
|
||||
func (m *Manager) outputMessageAsJSON(msg *types.ClineMessage) error {
|
||||
jsonBytes, err := json.MarshalIndent(msg, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal message as JSON: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println(string(jsonBytes))
|
||||
return nil
|
||||
}
|
||||
|
||||
// getCurrentMessageCount gets the current message count without displaying messages
|
||||
func (m *Manager) getCurrentMessageCount(ctx context.Context) (int, error) {
|
||||
state, err := m.client.State.GetLatestState(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get state: %w", err)
|
||||
}
|
||||
|
||||
messages, err := m.extractMessagesFromState(state.StateJson)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to extract messages: %w", err)
|
||||
}
|
||||
|
||||
return len(messages), nil
|
||||
}
|
||||
|
||||
// loadAndDisplayRecentHistory loads and displays recent conversation history and returns the total number of existing messages
|
||||
func (m *Manager) loadAndDisplayRecentHistory(ctx context.Context) (int, error) {
|
||||
// Get the latest state which contains messages
|
||||
state, err := m.client.State.GetLatestState(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get state: %w", err)
|
||||
}
|
||||
|
||||
// Parse the state JSON to extract messages
|
||||
messages, err := m.extractMessagesFromState(state.StateJson)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to extract messages: %w", err)
|
||||
}
|
||||
|
||||
if len(messages) == 0 {
|
||||
fmt.Println("No conversation history found.")
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Show only the last 100 messages by default
|
||||
const maxHistoryMessages = 100
|
||||
totalMessages := len(messages)
|
||||
startIndex := 0
|
||||
|
||||
if totalMessages > maxHistoryMessages {
|
||||
startIndex = totalMessages - maxHistoryMessages
|
||||
fmt.Printf("--- Conversation history (%d of %d messages) ---\n", maxHistoryMessages, totalMessages)
|
||||
} else {
|
||||
fmt.Printf("--- Conversation history (%d messages) ---\n", totalMessages)
|
||||
}
|
||||
|
||||
// Display recent messages
|
||||
for i := startIndex; i < len(messages); i++ {
|
||||
msg := messages[i]
|
||||
|
||||
// Display the message
|
||||
m.displayMessage(msg, false, false, i)
|
||||
}
|
||||
|
||||
// Return the total number of messages in the conversation
|
||||
return totalMessages, nil
|
||||
}
|
||||
|
||||
// extractMessagesFromState parses the state JSON and extracts messages
|
||||
func (m *Manager) extractMessagesFromState(stateJson string) ([]*types.ClineMessage, error) {
|
||||
return types.ExtractMessagesFromStateJSON(stateJson)
|
||||
}
|
||||
|
||||
// GetState returns the current conversation state
|
||||
func (m *Manager) GetState() *types.ConversationState {
|
||||
return m.state
|
||||
}
|
||||
|
||||
// Cleanup cleans up resources
|
||||
func (m *Manager) Cleanup() {
|
||||
// Clean up streaming display resources if needed
|
||||
if m.streamingDisplay != nil {
|
||||
m.streamingDisplay.Cleanup()
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package task
|
||||
|
||||
// StreamCoordinator manages coordination between SubscribeToState and SubscribeToPartialMessage streams
|
||||
type StreamCoordinator struct {
|
||||
conversationTurnStartIndex int // First message index of current turn
|
||||
processedInCurrentTurn map[string]bool // What we've handled in THIS turn
|
||||
}
|
||||
|
||||
// NewStreamCoordinator creates a new stream coordinator
|
||||
func NewStreamCoordinator() *StreamCoordinator {
|
||||
return &StreamCoordinator{
|
||||
conversationTurnStartIndex: 0,
|
||||
processedInCurrentTurn: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
// SetConversationTurnStartIndex sets the starting index for the current conversation turn
|
||||
func (sc *StreamCoordinator) SetConversationTurnStartIndex(index int) {
|
||||
sc.conversationTurnStartIndex = index
|
||||
}
|
||||
|
||||
// GetConversationTurnStartIndex returns the starting index for the current conversation turn
|
||||
func (sc *StreamCoordinator) GetConversationTurnStartIndex() int {
|
||||
return sc.conversationTurnStartIndex
|
||||
}
|
||||
|
||||
// MarkProcessedInCurrentTurn marks an item as processed in the current turn
|
||||
func (sc *StreamCoordinator) MarkProcessedInCurrentTurn(key string) {
|
||||
sc.processedInCurrentTurn[key] = true
|
||||
}
|
||||
|
||||
// IsProcessedInCurrentTurn checks if an item has been processed in the current turn
|
||||
func (sc *StreamCoordinator) IsProcessedInCurrentTurn(key string) bool {
|
||||
return sc.processedInCurrentTurn[key]
|
||||
}
|
||||
|
||||
// CompleteTurn resets the coordinator for the next conversation turn
|
||||
func (sc *StreamCoordinator) CompleteTurn(totalMessages int) {
|
||||
sc.conversationTurnStartIndex = totalMessages
|
||||
sc.processedInCurrentTurn = make(map[string]bool)
|
||||
}
|
||||
@@ -1,327 +0,0 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ClineMessage represents a conversation message in the CLI
|
||||
type ClineMessage struct {
|
||||
Type MessageType `json:"type"`
|
||||
Text string `json:"text"`
|
||||
Timestamp int64 `json:"ts"`
|
||||
Reasoning string `json:"reasoning,omitempty"`
|
||||
Say string `json:"say,omitempty"`
|
||||
Ask string `json:"ask,omitempty"`
|
||||
Partial bool `json:"partial,omitempty"`
|
||||
Images []string `json:"images,omitempty"`
|
||||
Files []string `json:"files,omitempty"`
|
||||
LastCheckpointHash string `json:"lastCheckpointHash,omitempty"`
|
||||
IsCheckpointCheckedOut bool `json:"isCheckpointCheckedOut,omitempty"`
|
||||
IsOperationOutsideWorkspace bool `json:"isOperationOutsideWorkspace,omitempty"`
|
||||
}
|
||||
|
||||
// MessageType represents the type of message
|
||||
type MessageType string
|
||||
|
||||
const (
|
||||
MessageTypeAsk MessageType = "ask"
|
||||
MessageTypeSay MessageType = "say"
|
||||
)
|
||||
|
||||
// AskType represents different types of ASK messages
|
||||
type AskType string
|
||||
|
||||
const (
|
||||
AskTypeFollowup AskType = "followup"
|
||||
AskTypePlanModeRespond AskType = "plan_mode_respond"
|
||||
AskTypeCommand AskType = "command"
|
||||
AskTypeCommandOutput AskType = "command_output"
|
||||
AskTypeCompletionResult AskType = "completion_result"
|
||||
AskTypeTool AskType = "tool"
|
||||
AskTypeAPIReqFailed AskType = "api_req_failed"
|
||||
AskTypeResumeTask AskType = "resume_task"
|
||||
AskTypeResumeCompletedTask AskType = "resume_completed_task"
|
||||
AskTypeMistakeLimitReached AskType = "mistake_limit_reached"
|
||||
AskTypeAutoApprovalMaxReached AskType = "auto_approval_max_req_reached"
|
||||
AskTypeBrowserActionLaunch AskType = "browser_action_launch"
|
||||
AskTypeUseMcpServer AskType = "use_mcp_server"
|
||||
AskTypeNewTask AskType = "new_task"
|
||||
AskTypeCondense AskType = "condense"
|
||||
AskTypeReportBug AskType = "report_bug"
|
||||
)
|
||||
|
||||
// SayType represents different types of SAY messages
|
||||
type SayType string
|
||||
|
||||
const (
|
||||
SayTypeTask SayType = "task"
|
||||
SayTypeError SayType = "error"
|
||||
SayTypeAPIReqStarted SayType = "api_req_started"
|
||||
SayTypeAPIReqFinished SayType = "api_req_finished"
|
||||
SayTypeText SayType = "text"
|
||||
SayTypeReasoning SayType = "reasoning"
|
||||
SayTypeCompletionResult SayType = "completion_result"
|
||||
SayTypeUserFeedback SayType = "user_feedback"
|
||||
SayTypeUserFeedbackDiff SayType = "user_feedback_diff"
|
||||
SayTypeAPIReqRetried SayType = "api_req_retried"
|
||||
SayTypeCommand SayType = "command"
|
||||
SayTypeCommandOutput SayType = "command_output"
|
||||
SayTypeTool SayType = "tool"
|
||||
SayTypeShellIntegrationWarning SayType = "shell_integration_warning"
|
||||
SayTypeBrowserActionLaunch SayType = "browser_action_launch"
|
||||
SayTypeBrowserAction SayType = "browser_action"
|
||||
SayTypeBrowserActionResult SayType = "browser_action_result"
|
||||
SayTypeMcpServerRequestStarted SayType = "mcp_server_request_started"
|
||||
SayTypeMcpServerResponse SayType = "mcp_server_response"
|
||||
SayTypeMcpNotification SayType = "mcp_notification"
|
||||
SayTypeUseMcpServer SayType = "use_mcp_server"
|
||||
SayTypeDiffError SayType = "diff_error"
|
||||
SayTypeDeletedAPIReqs SayType = "deleted_api_reqs"
|
||||
SayTypeClineignoreError SayType = "clineignore_error"
|
||||
SayTypeCheckpointCreated SayType = "checkpoint_created"
|
||||
SayTypeLoadMcpDocumentation SayType = "load_mcp_documentation"
|
||||
SayTypeInfo SayType = "info"
|
||||
SayTypeTaskProgress SayType = "task_progress"
|
||||
)
|
||||
|
||||
// ToolMessage represents a tool-related message
|
||||
type ToolMessage struct {
|
||||
Tool string `json:"tool"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Diff string `json:"diff,omitempty"`
|
||||
Regex string `json:"regex,omitempty"`
|
||||
FilePattern string `json:"filePattern,omitempty"`
|
||||
OperationIsLocatedInWorkspace *bool `json:"operationIsLocatedInWorkspace,omitempty"`
|
||||
}
|
||||
|
||||
// ToolType represents different types of tools
|
||||
type ToolType string
|
||||
|
||||
const (
|
||||
ToolTypeEditedExistingFile ToolType = "editedExistingFile"
|
||||
ToolTypeNewFileCreated ToolType = "newFileCreated"
|
||||
ToolTypeReadFile ToolType = "readFile"
|
||||
ToolTypeListFilesTopLevel ToolType = "listFilesTopLevel"
|
||||
ToolTypeListFilesRecursive ToolType = "listFilesRecursive"
|
||||
ToolTypeListCodeDefinitionNames ToolType = "listCodeDefinitionNames"
|
||||
ToolTypeSearchFiles ToolType = "searchFiles"
|
||||
ToolTypeWebFetch ToolType = "webFetch"
|
||||
ToolTypeSummarizeTask ToolType = "summarizeTask"
|
||||
)
|
||||
|
||||
// AskData represents the parsed structure of an ASK message
|
||||
type AskData struct {
|
||||
Question string `json:"question"`
|
||||
Response string `json:"response"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
// APIRequestInfo represents API request information
|
||||
type APIRequestInfo struct {
|
||||
Request string `json:"request,omitempty"`
|
||||
TokensIn int `json:"tokensIn,omitempty"`
|
||||
TokensOut int `json:"tokensOut,omitempty"`
|
||||
CacheWrites int `json:"cacheWrites,omitempty"`
|
||||
CacheReads int `json:"cacheReads,omitempty"`
|
||||
Cost float64 `json:"cost,omitempty"`
|
||||
CancelReason string `json:"cancelReason,omitempty"`
|
||||
StreamingFailedMessage string `json:"streamingFailedMessage,omitempty"`
|
||||
RetryStatus *APIRequestRetryStatus `json:"retryStatus,omitempty"`
|
||||
}
|
||||
|
||||
// APIRequestRetryStatus represents retry status information
|
||||
type APIRequestRetryStatus struct {
|
||||
Attempt int `json:"attempt"`
|
||||
MaxAttempts int `json:"maxAttempts"`
|
||||
DelaySec int `json:"delaySec"`
|
||||
ErrorSnippet string `json:"errorSnippet,omitempty"`
|
||||
}
|
||||
|
||||
// GetTimestamp returns a formatted timestamp string
|
||||
func (m *ClineMessage) GetTimestamp() string {
|
||||
return time.Unix(m.Timestamp/1000, 0).Format("15:04:05")
|
||||
}
|
||||
|
||||
// IsAsk returns true if this is an ASK message
|
||||
func (m *ClineMessage) IsAsk() bool {
|
||||
return m.Type == MessageTypeAsk
|
||||
}
|
||||
|
||||
// IsSay returns true if this is a SAY message
|
||||
func (m *ClineMessage) IsSay() bool {
|
||||
return m.Type == MessageTypeSay
|
||||
}
|
||||
|
||||
// GetMessageKey returns a unique key for this message based on timestamp
|
||||
func (m *ClineMessage) GetMessageKey() string {
|
||||
return strconv.FormatInt(m.Timestamp, 10)
|
||||
}
|
||||
|
||||
// ExtractMessagesFromStateJSON parses the state JSON and extracts messages
|
||||
func ExtractMessagesFromStateJSON(stateJson string) ([]*ClineMessage, error) {
|
||||
// Parse the state JSON to extract clineMessages
|
||||
var rawState map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(stateJson), &rawState); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse state JSON: %w", err)
|
||||
}
|
||||
|
||||
// Try to extract clineMessages
|
||||
clineMessagesRaw, exists := rawState["clineMessages"]
|
||||
if !exists {
|
||||
return []*ClineMessage{}, nil
|
||||
}
|
||||
|
||||
// Convert to JSON and back to get proper Message structs
|
||||
clineMessagesJson, err := json.Marshal(clineMessagesRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal clineMessages: %w", err)
|
||||
}
|
||||
|
||||
var messages []*ClineMessage
|
||||
if err := json.Unmarshal(clineMessagesJson, &messages); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal clineMessages: %w", err)
|
||||
}
|
||||
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
// ConvertProtoToMessage converts a protobuf ClineMessage to our local Message struct
|
||||
func ConvertProtoToMessage(protoMsg *cline.ClineMessage) *ClineMessage {
|
||||
var msgType MessageType
|
||||
var say, ask string
|
||||
|
||||
// Convert message type
|
||||
switch protoMsg.Type {
|
||||
case cline.ClineMessageType_ASK:
|
||||
msgType = MessageTypeAsk
|
||||
ask = convertProtoAskType(protoMsg.Ask)
|
||||
case cline.ClineMessageType_SAY:
|
||||
msgType = MessageTypeSay
|
||||
say = convertProtoSayType(protoMsg.Say)
|
||||
default:
|
||||
msgType = MessageTypeSay
|
||||
say = "unknown"
|
||||
}
|
||||
|
||||
return &ClineMessage{
|
||||
Type: msgType,
|
||||
Text: protoMsg.Text,
|
||||
Timestamp: protoMsg.Ts,
|
||||
Reasoning: protoMsg.Reasoning,
|
||||
Say: say,
|
||||
Ask: ask,
|
||||
Partial: protoMsg.Partial,
|
||||
LastCheckpointHash: protoMsg.LastCheckpointHash,
|
||||
IsCheckpointCheckedOut: protoMsg.IsCheckpointCheckedOut,
|
||||
IsOperationOutsideWorkspace: protoMsg.IsOperationOutsideWorkspace,
|
||||
}
|
||||
}
|
||||
|
||||
// convertProtoAskType converts protobuf ask type to string
|
||||
func convertProtoAskType(askType cline.ClineAsk) string {
|
||||
switch askType {
|
||||
case cline.ClineAsk_FOLLOWUP:
|
||||
return string(AskTypeFollowup)
|
||||
case cline.ClineAsk_PLAN_MODE_RESPOND:
|
||||
return string(AskTypePlanModeRespond)
|
||||
case cline.ClineAsk_COMMAND:
|
||||
return string(AskTypeCommand)
|
||||
case cline.ClineAsk_COMMAND_OUTPUT:
|
||||
return string(AskTypeCommandOutput)
|
||||
case cline.ClineAsk_COMPLETION_RESULT:
|
||||
return string(AskTypeCompletionResult)
|
||||
case cline.ClineAsk_TOOL:
|
||||
return string(AskTypeTool)
|
||||
case cline.ClineAsk_API_REQ_FAILED:
|
||||
return string(AskTypeAPIReqFailed)
|
||||
case cline.ClineAsk_RESUME_TASK:
|
||||
return string(AskTypeResumeTask)
|
||||
case cline.ClineAsk_RESUME_COMPLETED_TASK:
|
||||
return string(AskTypeResumeCompletedTask)
|
||||
case cline.ClineAsk_MISTAKE_LIMIT_REACHED:
|
||||
return string(AskTypeMistakeLimitReached)
|
||||
case cline.ClineAsk_AUTO_APPROVAL_MAX_REQ_REACHED:
|
||||
return string(AskTypeAutoApprovalMaxReached)
|
||||
case cline.ClineAsk_BROWSER_ACTION_LAUNCH:
|
||||
return string(AskTypeBrowserActionLaunch)
|
||||
case cline.ClineAsk_USE_MCP_SERVER:
|
||||
return string(AskTypeUseMcpServer)
|
||||
case cline.ClineAsk_NEW_TASK:
|
||||
return string(AskTypeNewTask)
|
||||
case cline.ClineAsk_CONDENSE:
|
||||
return string(AskTypeCondense)
|
||||
case cline.ClineAsk_REPORT_BUG:
|
||||
return string(AskTypeReportBug)
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// convertProtoSayType converts protobuf say type to string
|
||||
func convertProtoSayType(sayType cline.ClineSay) string {
|
||||
switch sayType {
|
||||
case cline.ClineSay_TASK:
|
||||
return string(SayTypeTask)
|
||||
case cline.ClineSay_ERROR:
|
||||
return string(SayTypeError)
|
||||
case cline.ClineSay_API_REQ_STARTED:
|
||||
return string(SayTypeAPIReqStarted)
|
||||
case cline.ClineSay_API_REQ_FINISHED:
|
||||
return string(SayTypeAPIReqFinished)
|
||||
case cline.ClineSay_TEXT:
|
||||
return string(SayTypeText)
|
||||
case cline.ClineSay_REASONING:
|
||||
return string(SayTypeReasoning)
|
||||
case cline.ClineSay_COMPLETION_RESULT_SAY:
|
||||
return string(SayTypeCompletionResult)
|
||||
case cline.ClineSay_USER_FEEDBACK:
|
||||
return string(SayTypeUserFeedback)
|
||||
case cline.ClineSay_USER_FEEDBACK_DIFF:
|
||||
return string(SayTypeUserFeedbackDiff)
|
||||
case cline.ClineSay_API_REQ_RETRIED:
|
||||
return string(SayTypeAPIReqRetried)
|
||||
case cline.ClineSay_COMMAND_SAY:
|
||||
return string(SayTypeCommand)
|
||||
case cline.ClineSay_COMMAND_OUTPUT_SAY:
|
||||
return string(SayTypeCommandOutput)
|
||||
case cline.ClineSay_TOOL_SAY:
|
||||
return string(SayTypeTool)
|
||||
case cline.ClineSay_SHELL_INTEGRATION_WARNING:
|
||||
return string(SayTypeShellIntegrationWarning)
|
||||
case cline.ClineSay_BROWSER_ACTION_LAUNCH_SAY:
|
||||
return string(SayTypeBrowserActionLaunch)
|
||||
case cline.ClineSay_BROWSER_ACTION:
|
||||
return string(SayTypeBrowserAction)
|
||||
case cline.ClineSay_BROWSER_ACTION_RESULT:
|
||||
return string(SayTypeBrowserActionResult)
|
||||
case cline.ClineSay_MCP_SERVER_REQUEST_STARTED:
|
||||
return string(SayTypeMcpServerRequestStarted)
|
||||
case cline.ClineSay_MCP_SERVER_RESPONSE:
|
||||
return string(SayTypeMcpServerResponse)
|
||||
case cline.ClineSay_MCP_NOTIFICATION:
|
||||
return string(SayTypeMcpNotification)
|
||||
case cline.ClineSay_USE_MCP_SERVER_SAY:
|
||||
return string(SayTypeUseMcpServer)
|
||||
case cline.ClineSay_DIFF_ERROR:
|
||||
return string(SayTypeDiffError)
|
||||
case cline.ClineSay_DELETED_API_REQS:
|
||||
return string(SayTypeDeletedAPIReqs)
|
||||
case cline.ClineSay_CLINEIGNORE_ERROR:
|
||||
return string(SayTypeClineignoreError)
|
||||
case cline.ClineSay_CHECKPOINT_CREATED:
|
||||
return string(SayTypeCheckpointCreated)
|
||||
case cline.ClineSay_LOAD_MCP_DOCUMENTATION:
|
||||
return string(SayTypeLoadMcpDocumentation)
|
||||
case cline.ClineSay_INFO:
|
||||
return string(SayTypeInfo)
|
||||
case cline.ClineSay_TASK_PROGRESS:
|
||||
return string(SayTypeTaskProgress)
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ConversationState manages the state of the conversation
|
||||
type ConversationState struct {
|
||||
mu sync.RWMutex
|
||||
StreamingMessage *StreamingMessage `json:"streamingMessage,omitempty"`
|
||||
}
|
||||
|
||||
// StreamingMessage manages state for streaming message display
|
||||
type StreamingMessage struct {
|
||||
CurrentKey string `json:"currentKey"`
|
||||
LastText string `json:"lastText"`
|
||||
LastToolMessage string `json:"lastToolMessage,omitempty"`
|
||||
}
|
||||
|
||||
// NewConversationState creates a new conversation state
|
||||
func NewConversationState() *ConversationState {
|
||||
return &ConversationState{
|
||||
StreamingMessage: &StreamingMessage{},
|
||||
}
|
||||
}
|
||||
|
||||
// SetStreamingMessage updates the streaming message state
|
||||
func (cs *ConversationState) SetStreamingMessage(key, text string) {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
cs.StreamingMessage.CurrentKey = key
|
||||
cs.StreamingMessage.LastText = text
|
||||
}
|
||||
|
||||
// GetStreamingMessage returns the current streaming message state
|
||||
func (cs *ConversationState) GetStreamingMessage() *StreamingMessage {
|
||||
cs.mu.RLock()
|
||||
defer cs.mu.RUnlock()
|
||||
return &StreamingMessage{
|
||||
CurrentKey: cs.StreamingMessage.CurrentKey,
|
||||
LastText: cs.StreamingMessage.LastText,
|
||||
LastToolMessage: cs.StreamingMessage.LastToolMessage,
|
||||
}
|
||||
}
|
||||
|
||||
// Clear resets state
|
||||
func (cs *ConversationState) Clear() {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
cs.StreamingMessage = &StreamingMessage{}
|
||||
}
|
||||
|
||||
// ExtensionState represents the server-side extension state structure
|
||||
type ExtensionState struct {
|
||||
CurrentTaskItem *CurrentTaskItem `json:"currentTaskItem,omitempty"`
|
||||
}
|
||||
|
||||
// CurrentTaskItem - minimal struct with just what we need
|
||||
type CurrentTaskItem struct {
|
||||
Id string `json:"id"`
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
// These will be set at build time via ldflags
|
||||
Version = "dev"
|
||||
Commit = "unknown"
|
||||
Date = "unknown"
|
||||
BuiltBy = "unknown"
|
||||
)
|
||||
|
||||
// NewVersionCommand creates the version command
|
||||
func NewVersionCommand() *cobra.Command {
|
||||
var short bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Show version information",
|
||||
Long: `Display version information for the Cline Go host.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if short {
|
||||
fmt.Println(Version)
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("Cline Go Host\n")
|
||||
fmt.Printf("Version: %s\n", Version)
|
||||
fmt.Printf("Commit: %s\n", Commit)
|
||||
fmt.Printf("Built: %s\n", Date)
|
||||
fmt.Printf("Built by: %s\n", BuiltBy)
|
||||
fmt.Printf("Go version: %s\n", runtime.Version())
|
||||
fmt.Printf("OS/Arch: %s/%s\n", runtime.GOOS, runtime.GOARCH)
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&short, "short", false, "show only version number")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
package common
|
||||
|
||||
// WE WILL HAVE TO MIGRATE THIS FROM DATA TO v1 LATER
|
||||
const SETTINGS_SUBFOLDER = "data"
|
||||
|
||||
const DEFAULT_CLINE_CORE_PORT = 50052
|
||||
@@ -1,54 +0,0 @@
|
||||
package common
|
||||
|
||||
// Database query constants for the SQLite locks database
|
||||
const (
|
||||
|
||||
// SelectInstanceLocksSQL selects all instance locks ordered by creation time
|
||||
SelectInstanceLocksSQL = `
|
||||
SELECT id, held_by, lock_type, lock_target, locked_at
|
||||
FROM locks
|
||||
WHERE lock_type = 'instance'
|
||||
ORDER BY locked_at ASC
|
||||
`
|
||||
|
||||
SelectInstanceLockByHolderSQL = `
|
||||
SELECT held_by, lock_target, locked_at
|
||||
FROM locks
|
||||
WHERE held_by = ? AND lock_type = 'instance'
|
||||
`
|
||||
SelectInstanceLockHoldersAscSQL = `
|
||||
SELECT held_by, lock_target, locked_at
|
||||
FROM locks
|
||||
WHERE lock_type = 'instance'
|
||||
ORDER BY locked_at ASC
|
||||
`
|
||||
|
||||
// DeleteInstanceLockSQL deletes an instance lock by address
|
||||
DeleteInstanceLockSQL = `
|
||||
DELETE FROM locks
|
||||
WHERE held_by = ? AND lock_type = 'instance'
|
||||
`
|
||||
|
||||
InsertFileLockSQL = `
|
||||
INSERT INTO locks (held_by, lock_type, lock_target, locked_at)
|
||||
VALUES (?, 'file', ?, ?)
|
||||
`
|
||||
|
||||
// DeleteFileLockSQL deletes a file lock by holder and target
|
||||
DeleteFileLockSQL = `
|
||||
DELETE FROM locks
|
||||
WHERE held_by = ? AND lock_type = 'file' AND lock_target = ?
|
||||
`
|
||||
|
||||
// CountInstanceLockSQL counts instance locks for a given address
|
||||
CountInstanceLockSQL = `
|
||||
SELECT COUNT(*) FROM locks
|
||||
WHERE held_by = ? AND lock_type = 'instance'
|
||||
`
|
||||
|
||||
// InsertInstanceLockSQL inserts or replaces an instance lock
|
||||
InsertInstanceLockSQL = `
|
||||
INSERT OR REPLACE INTO locks (held_by, lock_type, lock_target, locked_at)
|
||||
VALUES (?, 'instance', ?, ?)
|
||||
`
|
||||
)
|
||||
@@ -1,54 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc/health/grpc_health_v1"
|
||||
)
|
||||
|
||||
// CoreInstanceInfo represents a discovered Cline instance
|
||||
// This is the canonical definition used across all CLI packages
|
||||
type CoreInstanceInfo struct {
|
||||
// Full core address including port
|
||||
Address string `json:"address"`
|
||||
// Host bridge service address that core holds (host is ALWAYS running on localhost FYI)
|
||||
HostServiceAddress string `json:"host_port"`
|
||||
Status grpc_health_v1.HealthCheckResponse_ServingStatus `json:"status"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
ProcessPID int `json:"process_pid,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
}
|
||||
|
||||
func (c *CoreInstanceInfo) CorePort() int {
|
||||
_, port, _ := ParseHostPort(c.Address)
|
||||
return port
|
||||
}
|
||||
|
||||
func (c *CoreInstanceInfo) HostPort() int {
|
||||
_, port, _ := ParseHostPort(c.HostServiceAddress)
|
||||
return port
|
||||
}
|
||||
|
||||
func (c *CoreInstanceInfo) StatusString() string {
|
||||
return c.Status.String()
|
||||
}
|
||||
|
||||
// LockRow represents a row in the locks table
|
||||
type LockRow struct {
|
||||
ID int64 `json:"id"`
|
||||
HeldBy string `json:"held_by"`
|
||||
LockType string `json:"lock_type"`
|
||||
LockTarget string `json:"lock_target"`
|
||||
LockedAt int64 `json:"locked_at"`
|
||||
}
|
||||
|
||||
// InstancesOutput represents the JSON output format for instance listing
|
||||
type InstancesOutput struct {
|
||||
DefaultInstance string `json:"default_instance"`
|
||||
CoreInstances []CoreInstanceInfo `json:"instances"`
|
||||
}
|
||||
|
||||
type DefaultCoreInstance struct {
|
||||
Address string `json:"default_instance"`
|
||||
LastUpdated string `json:"last_updated"`
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/health/grpc_health_v1"
|
||||
)
|
||||
|
||||
// ParseHostPort parses a host:port address and returns the host and port separately
|
||||
func ParseHostPort(address string) (string, int, error) {
|
||||
host, portStr, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return host, port, nil
|
||||
}
|
||||
|
||||
// IsLocalAddress checks if the given host is a local/loopback address
|
||||
// Supports both IPv4 (localhost, 127.0.0.1) and IPv6 (::1) addresses
|
||||
func IsLocalAddress(host string) bool {
|
||||
// Handle common localhost names
|
||||
if host == "localhost" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Parse as IP and check if it's a loopback
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
return ip.IsLoopback()
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// PerformHealthCheck performs a gRPC health check on the given address
|
||||
// Will return UNKNOWN if the service is unreachable (error)
|
||||
func PerformHealthCheck(ctx context.Context, address string) (grpc_health_v1.HealthCheckResponse_ServingStatus, error) {
|
||||
conn, err := grpc.DialContext(ctx, address, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
return grpc_health_v1.HealthCheckResponse_UNKNOWN, err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
healthClient := grpc_health_v1.NewHealthClient(conn)
|
||||
resp, err := healthClient.Check(ctx, &grpc_health_v1.HealthCheckRequest{})
|
||||
if err != nil {
|
||||
return grpc_health_v1.HealthCheckResponse_UNKNOWN, err
|
||||
}
|
||||
|
||||
return resp.Status, nil
|
||||
}
|
||||
|
||||
// It's healthy if we can reach it and it responds with SERVING
|
||||
func IsInstanceHealthy(ctx context.Context, address string) bool {
|
||||
status, err := PerformHealthCheck(ctx, address)
|
||||
return err == nil && status == grpc_health_v1.HealthCheckResponse_SERVING
|
||||
}
|
||||
|
||||
// It's (likely) our instance if we can reach it and it responds to health checks
|
||||
func IsInstanceOurs(ctx context.Context, address string) bool {
|
||||
_, err := PerformHealthCheck(ctx, address)
|
||||
return err != nil
|
||||
}
|
||||
|
||||
// (unreachable or not serving)
|
||||
func IsInstanceStale(ctx context.Context, address string) (grpc_health_v1.HealthCheckResponse_ServingStatus, bool, error) {
|
||||
status, err := PerformHealthCheck(ctx, address)
|
||||
isStale := err != nil || status != grpc_health_v1.HealthCheckResponse_SERVING
|
||||
return status, isStale, err
|
||||
}
|
||||
|
||||
// IsPortAvailable checks if a port is available for binding
|
||||
func IsPortAvailable(port int) bool {
|
||||
address := fmt.Sprintf("localhost:%d", port)
|
||||
listener, err := net.Listen("tcp", address)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
listener.Close()
|
||||
return true
|
||||
}
|
||||
|
||||
// FindAvailablePortPair finds two available ports by letting the OS allocate them
|
||||
func FindAvailablePortPair() (corePort, hostPort int, err error) {
|
||||
coreListener, err := net.Listen("tcp", ":0")
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
defer coreListener.Close()
|
||||
|
||||
hostListener, err := net.Listen("tcp", ":0")
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
defer hostListener.Close()
|
||||
|
||||
corePort = coreListener.Addr().(*net.TCPAddr).Port
|
||||
hostPort = hostListener.Addr().(*net.TCPAddr).Port
|
||||
|
||||
return corePort, hostPort, nil
|
||||
}
|
||||
|
||||
// NormalizeAddressForGRPC converts address to host:port for grpc client with proper normalization
|
||||
func NormalizeAddressForGRPC(address string) (string, error) {
|
||||
host, port, err := ParseHostPort(address)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Normalize local addresses to localhost for gRPC compatibility
|
||||
if IsLocalAddress(host) {
|
||||
return fmt.Sprintf("localhost:%d", port), nil
|
||||
}
|
||||
|
||||
return address, nil
|
||||
}
|
||||
|
||||
// RetryOperation performs an operation with retry logic
|
||||
func RetryOperation(maxRetries int, timeoutPerAttempt time.Duration, operation func() error) error {
|
||||
var lastErr error
|
||||
|
||||
for attempt := 1; attempt <= maxRetries; attempt++ {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeoutPerAttempt)
|
||||
|
||||
// Create a channel to capture the operation result
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- operation()
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
cancel()
|
||||
if err == nil {
|
||||
return nil // Success
|
||||
}
|
||||
lastErr = err
|
||||
case <-ctx.Done():
|
||||
cancel()
|
||||
lastErr = ctx.Err()
|
||||
}
|
||||
|
||||
// Add delay between attempts (except for the last one)
|
||||
if attempt < maxRetries {
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("operation failed after %d attempts: %w", maxRetries, lastErr)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,351 +0,0 @@
|
||||
package hostbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
proto "github.com/cline/grpc-go/host"
|
||||
)
|
||||
|
||||
// diffSession represents an in-memory diff editing session
|
||||
type diffSession struct {
|
||||
originalPath string // File path from OpenDiff request
|
||||
originalContent []byte // Original file content (for comparison)
|
||||
currentContent []byte // Current modified content
|
||||
lines []string // Current content split into lines
|
||||
encoding string // File encoding (default: utf8)
|
||||
}
|
||||
|
||||
// DiffService implements the proto.DiffServiceServer interface
|
||||
type DiffService struct {
|
||||
proto.UnimplementedDiffServiceServer
|
||||
verbose bool
|
||||
sessions *sync.Map // thread-safe: diffId -> *diffSession
|
||||
counter *int64 // atomic counter for unique IDs
|
||||
}
|
||||
|
||||
// NewDiffService creates a new DiffService
|
||||
func NewDiffService(verbose bool) *DiffService {
|
||||
counter := int64(0)
|
||||
return &DiffService{
|
||||
verbose: verbose,
|
||||
sessions: &sync.Map{},
|
||||
counter: &counter,
|
||||
}
|
||||
}
|
||||
|
||||
// generateDiffID creates a unique diff ID
|
||||
func (s *DiffService) generateDiffID() string {
|
||||
id := atomic.AddInt64(s.counter, 1)
|
||||
return fmt.Sprintf("diff_%d_%d", os.Getpid(), id)
|
||||
}
|
||||
|
||||
// splitLines splits content into lines, preserving line ending information
|
||||
func splitLines(content string) []string {
|
||||
if content == "" {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
lines := []string{}
|
||||
current := ""
|
||||
|
||||
for _, char := range content {
|
||||
if char == '\n' {
|
||||
lines = append(lines, current)
|
||||
current = ""
|
||||
} else if char != '\r' { // Skip \r characters, handle \r\n as \n
|
||||
current += string(char)
|
||||
}
|
||||
}
|
||||
|
||||
// Add the last line if it doesn't end with newline
|
||||
if current != "" {
|
||||
lines = append(lines, current)
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
// joinLines joins lines back into content with newlines
|
||||
func joinLines(lines []string) string {
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// OpenDiff opens a diff view for the specified file
|
||||
func (s *DiffService) OpenDiff(ctx context.Context, req *proto.OpenDiffRequest) (*proto.OpenDiffResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("OpenDiff called for path: %s", req.GetPath())
|
||||
}
|
||||
|
||||
diffID := s.generateDiffID()
|
||||
|
||||
var originalContent []byte
|
||||
|
||||
// Check if file exists and read original content
|
||||
if req.GetPath() != "" {
|
||||
if _, err := os.Stat(req.GetPath()); err == nil {
|
||||
// File exists, read its content
|
||||
var readErr error
|
||||
originalContent, readErr = ioutil.ReadFile(req.GetPath())
|
||||
if readErr != nil {
|
||||
return nil, fmt.Errorf("failed to read original file: %w", readErr)
|
||||
}
|
||||
} else {
|
||||
// File doesn't exist, use empty content
|
||||
originalContent = []byte{}
|
||||
}
|
||||
}
|
||||
|
||||
// Use provided content as the initial current content
|
||||
currentContent := []byte(req.GetContent())
|
||||
|
||||
// Create the diff session
|
||||
session := &diffSession{
|
||||
originalPath: req.GetPath(),
|
||||
originalContent: originalContent,
|
||||
currentContent: currentContent,
|
||||
lines: splitLines(req.GetContent()),
|
||||
encoding: "utf8", // Default encoding
|
||||
}
|
||||
|
||||
// Store the session
|
||||
s.sessions.Store(diffID, session)
|
||||
|
||||
if s.verbose {
|
||||
log.Printf("Created diff session: %s (original: %d bytes, current: %d bytes)",
|
||||
diffID, len(originalContent), len(currentContent))
|
||||
}
|
||||
|
||||
return &proto.OpenDiffResponse{
|
||||
DiffId: &diffID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetDocumentText returns the current content of the diff document
|
||||
func (s *DiffService) GetDocumentText(ctx context.Context, req *proto.GetDocumentTextRequest) (*proto.GetDocumentTextResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("GetDocumentText called for diff ID: %s", req.GetDiffId())
|
||||
}
|
||||
|
||||
sessionInterface, exists := s.sessions.Load(req.GetDiffId())
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("diff session not found: %s", req.GetDiffId())
|
||||
}
|
||||
|
||||
session := sessionInterface.(*diffSession)
|
||||
content := string(session.currentContent)
|
||||
|
||||
return &proto.GetDocumentTextResponse{
|
||||
Content: &content,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ReplaceText replaces text in the diff document using line-based operations
|
||||
func (s *DiffService) ReplaceText(ctx context.Context, req *proto.ReplaceTextRequest) (*proto.ReplaceTextResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("ReplaceText called for diff ID: %s, lines %d-%d",
|
||||
req.GetDiffId(), req.GetStartLine(), req.GetEndLine())
|
||||
}
|
||||
|
||||
sessionInterface, exists := s.sessions.Load(req.GetDiffId())
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("diff session not found: %s", req.GetDiffId())
|
||||
}
|
||||
|
||||
session := sessionInterface.(*diffSession)
|
||||
|
||||
startLine := int(req.GetStartLine())
|
||||
endLine := int(req.GetEndLine())
|
||||
newContent := req.GetContent()
|
||||
|
||||
// Validate line ranges
|
||||
if startLine < 0 {
|
||||
startLine = 0
|
||||
}
|
||||
if endLine < startLine {
|
||||
endLine = startLine
|
||||
}
|
||||
|
||||
// Split new content into lines
|
||||
newLines := splitLines(newContent)
|
||||
|
||||
// Ensure we have enough lines in the current content
|
||||
for len(session.lines) < endLine {
|
||||
session.lines = append(session.lines, "")
|
||||
}
|
||||
|
||||
// Replace the specified line range
|
||||
if endLine > len(session.lines) {
|
||||
// Extending beyond current content - append new lines
|
||||
session.lines = append(session.lines[:startLine], newLines...)
|
||||
} else {
|
||||
// Replace within existing content
|
||||
result := make([]string, 0, len(session.lines)-endLine+startLine+len(newLines))
|
||||
result = append(result, session.lines[:startLine]...)
|
||||
result = append(result, newLines...)
|
||||
result = append(result, session.lines[endLine:]...)
|
||||
session.lines = result
|
||||
}
|
||||
|
||||
// Update current content
|
||||
session.currentContent = []byte(joinLines(session.lines))
|
||||
|
||||
// Store the updated session
|
||||
s.sessions.Store(req.GetDiffId(), session)
|
||||
|
||||
if s.verbose {
|
||||
log.Printf("Updated diff session %s: %d lines, %d bytes",
|
||||
req.GetDiffId(), len(session.lines), len(session.currentContent))
|
||||
}
|
||||
|
||||
return &proto.ReplaceTextResponse{}, nil
|
||||
}
|
||||
|
||||
// ScrollDiff scrolls the diff view to a specific line (no-op for CLI)
|
||||
func (s *DiffService) ScrollDiff(ctx context.Context, req *proto.ScrollDiffRequest) (*proto.ScrollDiffResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("ScrollDiff called for diff ID: %s, line: %d", req.GetDiffId(), req.GetLine())
|
||||
}
|
||||
|
||||
// Verify session exists
|
||||
if _, exists := s.sessions.Load(req.GetDiffId()); !exists {
|
||||
return nil, fmt.Errorf("diff session not found: %s", req.GetDiffId())
|
||||
}
|
||||
|
||||
// In a CLI implementation, scrolling is a no-op
|
||||
// In a GUI implementation, this would scroll the view to the specified line
|
||||
return &proto.ScrollDiffResponse{}, nil
|
||||
}
|
||||
|
||||
// TruncateDocument truncates the diff document at the specified line
|
||||
func (s *DiffService) TruncateDocument(ctx context.Context, req *proto.TruncateDocumentRequest) (*proto.TruncateDocumentResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("TruncateDocument called for diff ID: %s, end line: %d", req.GetDiffId(), req.GetEndLine())
|
||||
}
|
||||
|
||||
sessionInterface, exists := s.sessions.Load(req.GetDiffId())
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("diff session not found: %s", req.GetDiffId())
|
||||
}
|
||||
|
||||
session := sessionInterface.(*diffSession)
|
||||
endLine := int(req.GetEndLine())
|
||||
|
||||
// Truncate lines at the specified position
|
||||
if endLine >= 0 && endLine < len(session.lines) {
|
||||
session.lines = session.lines[:endLine]
|
||||
session.currentContent = []byte(joinLines(session.lines))
|
||||
|
||||
// Store the updated session
|
||||
s.sessions.Store(req.GetDiffId(), session)
|
||||
|
||||
if s.verbose {
|
||||
log.Printf("Truncated diff session %s to %d lines", req.GetDiffId(), len(session.lines))
|
||||
}
|
||||
}
|
||||
|
||||
return &proto.TruncateDocumentResponse{}, nil
|
||||
}
|
||||
|
||||
// SaveDocument saves the diff document to the original file
|
||||
func (s *DiffService) SaveDocument(ctx context.Context, req *proto.SaveDocumentRequest) (*proto.SaveDocumentResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("SaveDocument called for diff ID: %s", req.GetDiffId())
|
||||
}
|
||||
|
||||
sessionInterface, exists := s.sessions.Load(req.GetDiffId())
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("diff session not found: %s", req.GetDiffId())
|
||||
}
|
||||
|
||||
session := sessionInterface.(*diffSession)
|
||||
|
||||
if session.originalPath == "" {
|
||||
return nil, fmt.Errorf("no file path specified for diff session: %s", req.GetDiffId())
|
||||
}
|
||||
|
||||
// Create parent directories if they don't exist
|
||||
dir := filepath.Dir(session.originalPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create directories: %w", err)
|
||||
}
|
||||
|
||||
// Write the current content to the original file
|
||||
if err := ioutil.WriteFile(session.originalPath, session.currentContent, 0644); err != nil {
|
||||
return nil, fmt.Errorf("failed to save file: %w", err)
|
||||
}
|
||||
|
||||
if s.verbose {
|
||||
log.Printf("Saved diff session %s to file: %s (%d bytes)",
|
||||
req.GetDiffId(), session.originalPath, len(session.currentContent))
|
||||
}
|
||||
|
||||
return &proto.SaveDocumentResponse{}, nil
|
||||
}
|
||||
|
||||
// CloseAllDiffs closes all diff views and cleans up all sessions
|
||||
func (s *DiffService) CloseAllDiffs(ctx context.Context, req *proto.CloseAllDiffsRequest) (*proto.CloseAllDiffsResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("CloseAllDiffs called")
|
||||
}
|
||||
|
||||
var count int64
|
||||
|
||||
s.sessions.Range(func(key, value any) bool {
|
||||
// Optional: attempt to close if the value supports it
|
||||
if c, ok := value.(interface{ Close() error }); ok {
|
||||
_ = c.Close() // best-effort; ignore error
|
||||
}
|
||||
|
||||
s.sessions.Delete(key)
|
||||
atomic.AddInt64(&count, 1)
|
||||
return true
|
||||
})
|
||||
|
||||
if s.verbose {
|
||||
log.Printf("Closed %d diff sessions", count)
|
||||
}
|
||||
|
||||
return &proto.CloseAllDiffsResponse{}, nil
|
||||
}
|
||||
|
||||
// OpenMultiFileDiff displays a diff view comparing before/after states for multiple files
|
||||
func (s *DiffService) OpenMultiFileDiff(ctx context.Context, req *proto.OpenMultiFileDiffRequest) (*proto.OpenMultiFileDiffResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("OpenMultiFileDiff called with title: %s, %d files", req.GetTitle(), len(req.GetDiffs()))
|
||||
}
|
||||
|
||||
// In a CLI implementation, we could display the diffs to console
|
||||
// For now, we'll just log the information
|
||||
title := req.GetTitle()
|
||||
if title == "" {
|
||||
title = "Multi-file diff"
|
||||
}
|
||||
|
||||
if s.verbose {
|
||||
log.Printf("=== %s ===", title)
|
||||
for i, diff := range req.GetDiffs() {
|
||||
log.Printf("File %d: %s", i+1, diff.GetFilePath())
|
||||
log.Printf(" Left content: %d bytes", len(diff.GetLeftContent()))
|
||||
log.Printf(" Right content: %d bytes", len(diff.GetRightContent()))
|
||||
}
|
||||
}
|
||||
|
||||
// In a more sophisticated CLI implementation, we could:
|
||||
// 1. Use a diff library to generate unified diffs
|
||||
// 2. Display them with colors
|
||||
// 3. Allow navigation between files
|
||||
// For now, this is a no-op that just acknowledges the request
|
||||
|
||||
return &proto.OpenMultiFileDiffResponse{}, nil
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package hostbridge
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/cline/grpc-go/host"
|
||||
)
|
||||
|
||||
// WatchService implements the host.WatchServiceServer interface
|
||||
type WatchService struct {
|
||||
host.UnimplementedWatchServiceServer
|
||||
coreAddress string
|
||||
verbose bool
|
||||
}
|
||||
|
||||
// NewWatchService creates a new WatchService
|
||||
func NewWatchService(coreAddress string, verbose bool) *WatchService {
|
||||
return &WatchService{
|
||||
coreAddress: coreAddress,
|
||||
verbose: verbose,
|
||||
}
|
||||
}
|
||||
|
||||
// SubscribeToFile subscribes to file change notifications
|
||||
func (s *WatchService) SubscribeToFile(req *host.SubscribeToFileRequest, stream host.WatchService_SubscribeToFileServer) error {
|
||||
if s.verbose {
|
||||
log.Printf("SubscribeToFile called for path: %s", req.GetPath())
|
||||
}
|
||||
|
||||
// For console implementation, we'll just log that we would watch the file
|
||||
// In a real implementation, we'd use fsnotify or similar to watch file changes
|
||||
log.Printf("[Cline] Would watch file: %s", req.GetPath())
|
||||
|
||||
// Keep the stream open but don't send any events for now
|
||||
// In a real implementation, we'd send FileChangeEvent messages when files change
|
||||
<-stream.Context().Done()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package hostbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
proto "github.com/cline/grpc-go/host"
|
||||
)
|
||||
|
||||
// WindowService implements the proto.WindowServiceServer interface
|
||||
type WindowService struct {
|
||||
proto.UnimplementedWindowServiceServer
|
||||
coreAddress string
|
||||
verbose bool
|
||||
}
|
||||
|
||||
// NewWindowService creates a new WindowService
|
||||
func NewWindowService(coreAddress string, verbose bool) *WindowService {
|
||||
return &WindowService{
|
||||
coreAddress: coreAddress,
|
||||
verbose: verbose,
|
||||
}
|
||||
}
|
||||
|
||||
// ShowTextDocument opens a text document for viewing/editing
|
||||
func (s *WindowService) ShowTextDocument(ctx context.Context, req *proto.ShowTextDocumentRequest) (*proto.TextEditorInfo, error) {
|
||||
if s.verbose {
|
||||
log.Printf("ShowTextDocument called for path: %s", req.GetPath())
|
||||
}
|
||||
|
||||
// For console implementation, we'll just log that we would open the document
|
||||
fmt.Printf("[Cline] Would open document: %s\n", req.GetPath())
|
||||
|
||||
return &proto.TextEditorInfo{
|
||||
DocumentPath: req.GetPath(),
|
||||
IsActive: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ShowOpenDialogue shows a file open dialog
|
||||
func (s *WindowService) ShowOpenDialogue(ctx context.Context, req *proto.ShowOpenDialogueRequest) (*proto.SelectedResources, error) {
|
||||
if s.verbose {
|
||||
log.Printf("ShowOpenDialogue called")
|
||||
}
|
||||
|
||||
// For console implementation, return empty list (user cancelled)
|
||||
return &proto.SelectedResources{
|
||||
Paths: []string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ShowMessage displays a message to the user
|
||||
func (s *WindowService) ShowMessage(ctx context.Context, req *proto.ShowMessageRequest) (*proto.SelectedResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("ShowMessage called: %s", req.GetMessage())
|
||||
}
|
||||
|
||||
// Display message to console
|
||||
fmt.Printf("[Cline] %s\n", req.GetMessage())
|
||||
|
||||
return &proto.SelectedResponse{}, nil
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package hostbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/cline/grpc-go/host"
|
||||
)
|
||||
|
||||
// WorkspaceService implements the host.WorkspaceServiceServer interface
|
||||
type WorkspaceService struct {
|
||||
host.UnimplementedWorkspaceServiceServer
|
||||
coreAddress string
|
||||
verbose bool
|
||||
}
|
||||
|
||||
// NewWorkspaceService creates a new WorkspaceService
|
||||
func NewWorkspaceService(coreAddress string, verbose bool) *WorkspaceService {
|
||||
return &WorkspaceService{
|
||||
coreAddress: coreAddress,
|
||||
verbose: verbose,
|
||||
}
|
||||
}
|
||||
|
||||
// GetWorkspacePaths returns the workspace directory paths
|
||||
func (s *WorkspaceService) GetWorkspacePaths(ctx context.Context, req *host.GetWorkspacePathsRequest) (*host.GetWorkspacePathsResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("GetWorkspacePaths called")
|
||||
}
|
||||
|
||||
// Get current working directory as the workspace
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &host.GetWorkspacePathsResponse{
|
||||
Paths: []string{cwd},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SaveOpenDocumentIfDirty saves an open document if it has unsaved changes
|
||||
func (s *WorkspaceService) SaveOpenDocumentIfDirty(ctx context.Context, req *host.SaveOpenDocumentIfDirtyRequest) (*host.SaveOpenDocumentIfDirtyResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("SaveOpenDocumentIfDirty called for path: %s", req.GetPath())
|
||||
}
|
||||
|
||||
// For console implementation, we'll assume the document is already saved
|
||||
// In a real implementation, we'd check if the file has unsaved changes
|
||||
return &host.SaveOpenDocumentIfDirtyResponse{
|
||||
WasSaved: false, // Assume no changes to save
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetDiagnostics returns diagnostic information for a file
|
||||
func (s *WorkspaceService) GetDiagnostics(ctx context.Context, req *host.GetDiagnosticsRequest) (*host.GetDiagnosticsResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("GetDiagnostics called for path: %s", req.GetPath())
|
||||
}
|
||||
|
||||
// For console implementation, return empty diagnostics
|
||||
return &host.GetDiagnosticsResponse{
|
||||
Diagnostics: []*host.Diagnostic{},
|
||||
}, nil
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
package hostbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/atotto/clipboard"
|
||||
"github.com/cline/cli/pkg/cli"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
"github.com/cline/grpc-go/host"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
// Global shutdown channel - simple approach
|
||||
var globalShutdownCh chan struct{}
|
||||
|
||||
func init() {
|
||||
globalShutdownCh = make(chan struct{})
|
||||
}
|
||||
|
||||
// EnvService implements the host.EnvServiceServer interface
|
||||
type EnvService struct {
|
||||
host.UnimplementedEnvServiceServer
|
||||
verbose bool
|
||||
}
|
||||
|
||||
// NewEnvService creates a new EnvService
|
||||
func NewEnvService(verbose bool) *EnvService {
|
||||
return &EnvService{
|
||||
verbose: verbose,
|
||||
}
|
||||
}
|
||||
|
||||
// ClipboardWriteText writes text to the system clipboard
|
||||
func (s *EnvService) ClipboardWriteText(ctx context.Context, req *cline.StringRequest) (*cline.Empty, error) {
|
||||
if s.verbose {
|
||||
log.Printf("ClipboardWriteText called with text length: %d", len(req.GetValue()))
|
||||
}
|
||||
|
||||
err := clipboard.WriteAll(req.GetValue())
|
||||
if err != nil {
|
||||
if s.verbose {
|
||||
log.Printf("Failed to write to clipboard: %v", err)
|
||||
}
|
||||
// Don't fail if clipboard is not available (e.g., headless environment)
|
||||
}
|
||||
|
||||
return &cline.Empty{}, nil
|
||||
}
|
||||
|
||||
// ClipboardReadText reads text from the system clipboard
|
||||
func (s *EnvService) ClipboardReadText(ctx context.Context, req *cline.EmptyRequest) (*cline.String, error) {
|
||||
if s.verbose {
|
||||
log.Printf("ClipboardReadText called")
|
||||
}
|
||||
|
||||
text, err := clipboard.ReadAll()
|
||||
if err != nil {
|
||||
if s.verbose {
|
||||
log.Printf("Failed to read from clipboard: %v", err)
|
||||
}
|
||||
// Return empty string if clipboard is not available
|
||||
text = ""
|
||||
}
|
||||
|
||||
return &cline.String{
|
||||
Value: text,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetMachineId returns a stable machine identifier for telemetry distinctId purposes
|
||||
func (s *EnvService) GetMachineId(ctx context.Context, req *cline.EmptyRequest) (*cline.String, error) {
|
||||
if s.verbose {
|
||||
log.Printf("GetMachineId called")
|
||||
}
|
||||
|
||||
// TODO: Implement actual machine ID functionality
|
||||
// For now, return empty string
|
||||
return &cline.String{
|
||||
Value: "",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetHostVersion returns the host platform name and version
|
||||
func (s *EnvService) GetHostVersion(ctx context.Context, req *cline.EmptyRequest) (*host.GetHostVersionResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("GetHostVersion called")
|
||||
}
|
||||
|
||||
return &host.GetHostVersionResponse{
|
||||
Platform: proto.String("Cline CLI"),
|
||||
Version: proto.String(""),
|
||||
ClineType: proto.String("CLI"),
|
||||
ClineVersion: proto.String(cli.Version),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Shutdown initiates a graceful shutdown of the host bridge service
|
||||
func (s *EnvService) Shutdown(ctx context.Context, req *cline.EmptyRequest) (*cline.Empty, error) {
|
||||
if s.verbose {
|
||||
log.Printf("Shutdown requested via RPC")
|
||||
}
|
||||
|
||||
// Trigger global shutdown signal
|
||||
select {
|
||||
case globalShutdownCh <- struct{}{}:
|
||||
if s.verbose {
|
||||
log.Printf("Shutdown signal sent successfully")
|
||||
}
|
||||
default:
|
||||
if s.verbose {
|
||||
log.Printf("Shutdown signal already pending")
|
||||
}
|
||||
}
|
||||
|
||||
return &cline.Empty{}, nil
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
package hostbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
|
||||
"github.com/cline/grpc-go/host"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/health"
|
||||
"google.golang.org/grpc/health/grpc_health_v1"
|
||||
)
|
||||
|
||||
// GrpcServer provides gRPC hostbridge functionality
|
||||
type GrpcServer struct {
|
||||
port int
|
||||
verbose bool
|
||||
server *grpc.Server
|
||||
shutdownCh chan struct{}
|
||||
}
|
||||
|
||||
// NewGrpcServer creates a new GrpcServer
|
||||
func NewGrpcServer(port int, verbose bool) *GrpcServer {
|
||||
return &GrpcServer{
|
||||
port: port,
|
||||
verbose: verbose,
|
||||
shutdownCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start starts the gRPC hostbridge server
|
||||
func (s *GrpcServer) Start(ctx context.Context) error {
|
||||
if s.verbose {
|
||||
log.Printf("Starting gRPC hostbridge server on port %d", s.port)
|
||||
}
|
||||
|
||||
// Create listener
|
||||
lis, err := net.Listen("tcp", fmt.Sprintf(":%d", s.port))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to listen on port %d: %w", s.port, err)
|
||||
}
|
||||
|
||||
// Create gRPC server
|
||||
s.server = grpc.NewServer()
|
||||
|
||||
// Register health service
|
||||
healthServer := health.NewServer()
|
||||
healthServer.SetServingStatus("", grpc_health_v1.HealthCheckResponse_SERVING)
|
||||
grpc_health_v1.RegisterHealthServer(s.server, healthServer)
|
||||
|
||||
// Register services
|
||||
workspaceService := NewSimpleWorkspaceService(s.verbose)
|
||||
host.RegisterWorkspaceServiceServer(s.server, workspaceService)
|
||||
|
||||
windowService := NewWindowService(s.verbose)
|
||||
host.RegisterWindowServiceServer(s.server, windowService)
|
||||
|
||||
diffService := NewDiffService(s.verbose)
|
||||
host.RegisterDiffServiceServer(s.server, diffService)
|
||||
|
||||
envService := NewEnvService(s.verbose)
|
||||
host.RegisterEnvServiceServer(s.server, envService)
|
||||
|
||||
if s.verbose {
|
||||
log.Printf("Registered HealthService")
|
||||
log.Printf("Registered WorkspaceService")
|
||||
log.Printf("Registered WindowService")
|
||||
log.Printf("Registered DiffService")
|
||||
log.Printf("Registered EnvService")
|
||||
}
|
||||
|
||||
// Start server in goroutine
|
||||
go func() {
|
||||
if s.verbose {
|
||||
log.Printf("gRPC server listening on :%d", s.port)
|
||||
}
|
||||
if err := s.server.Serve(lis); err != nil {
|
||||
log.Printf("gRPC server error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for context cancellation or global shutdown signal
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if s.verbose {
|
||||
log.Println("Context cancelled, shutting down gRPC hostbridge server...")
|
||||
}
|
||||
case <-globalShutdownCh:
|
||||
if s.verbose {
|
||||
log.Println("Shutdown requested via RPC, shutting down gRPC hostbridge server...")
|
||||
}
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
s.server.GracefulStop()
|
||||
|
||||
if s.verbose {
|
||||
log.Println("gRPC hostbridge server stopped")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TriggerShutdown triggers a graceful shutdown of the server
|
||||
func (s *GrpcServer) TriggerShutdown() {
|
||||
select {
|
||||
case s.shutdownCh <- struct{}{}:
|
||||
// Shutdown signal sent
|
||||
default:
|
||||
// Channel already has a signal or is closed
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package hostbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
)
|
||||
|
||||
// Simple implementations that don't rely on proto files for now
|
||||
// This allows us to test the basic hostbridge structure
|
||||
|
||||
// SimpleService provides basic hostbridge functionality
|
||||
type SimpleService struct {
|
||||
coreAddress string
|
||||
verbose bool
|
||||
}
|
||||
|
||||
// NewSimpleService creates a new SimpleService
|
||||
func NewSimpleService(coreAddress string, verbose bool) *SimpleService {
|
||||
return &SimpleService{
|
||||
coreAddress: coreAddress,
|
||||
verbose: verbose,
|
||||
}
|
||||
}
|
||||
|
||||
// Start starts the simple hostbridge service
|
||||
func (s *SimpleService) Start(ctx context.Context) error {
|
||||
if s.verbose {
|
||||
log.Printf("Starting simple hostbridge service (connecting to core at %s)", s.coreAddress)
|
||||
}
|
||||
|
||||
// For now, just log that we're running
|
||||
fmt.Printf("[Cline Host Bridge] Service started on core address: %s\n", s.coreAddress)
|
||||
|
||||
// Keep running until context is cancelled
|
||||
<-ctx.Done()
|
||||
|
||||
if s.verbose {
|
||||
log.Println("Simple hostbridge service stopped")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user