Compare commits

..

1 Commits

Author SHA1 Message Date
Ocasta 6e940ab4b2 initial pass 2025-02-19 00:29:53 -08:00
178 changed files with 19800 additions and 18972 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
tooltip for each mode can be shown no matter what the current mode is
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
Add support for rendering mermaid graphs in the chat.
+10
View File
@@ -0,0 +1,10 @@
---
"claude-dev": patch
---
Improve Requesty provider integration
- Adding Cline headers to API requests, to enable targeted optimizations
- Read o3 reasoning effort from Cline config, not model name
- Show token information in task header
- Get total cost from response when available
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Update README.md to include Getting Started
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": minor
---
Test Minor
@@ -2,4 +2,4 @@
"claude-dev": patch
---
Test Patch
Add MCP Marketplace
-517
View File
@@ -1,517 +0,0 @@
# Cline Extension Architecture & Development Guide
## Project Overview
Cline is a VSCode extension that provides AI assistance through a combination of a core extension backend and a React-based webview frontend. The extension is built with TypeScript and follows a modular architecture pattern.
## Architecture Overview
```mermaid
graph TB
subgraph VSCode Extension Host
subgraph Core Extension
ExtensionEntry[Extension Entry<br/>src/extension.ts]
ClineProvider[ClineProvider<br/>src/core/webview/ClineProvider.ts]
ClineClass[Cline Class<br/>src/core/Cline.ts]
GlobalState[VSCode Global State]
SecretsStorage[VSCode Secrets Storage]
end
subgraph Webview UI
WebviewApp[React App<br/>webview-ui/src/App.tsx]
ExtStateContext[ExtensionStateContext<br/>webview-ui/src/context/ExtensionStateContext.tsx]
ReactComponents[React Components]
end
subgraph Storage
TaskStorage[Task Storage<br/>Per-Task Files & History]
CheckpointSystem[Git-based Checkpoints]
end
end
%% Core Extension Data Flow
ExtensionEntry --> ClineProvider
ClineProvider --> ClineClass
ClineClass --> GlobalState
ClineClass --> SecretsStorage
ClineClass --> TaskStorage
ClineClass --> CheckpointSystem
%% Webview Data Flow
WebviewApp --> ExtStateContext
ExtStateContext --> ReactComponents
%% Bidirectional Communication
ClineProvider <-->|postMessage| ExtStateContext
style GlobalState fill:#f9f,stroke:#333,stroke-width:2px
style SecretsStorage fill:#f9f,stroke:#333,stroke-width:2px
style ExtStateContext fill:#bbf,stroke:#333,stroke-width:2px
style ClineProvider fill:#bfb,stroke:#333,stroke-width:2px
```
## Definitions
- core extension: Anything inside the src folder starting with the Cline.ts file
- core extension state: Managed by the ClineProvider class in src/core/webview/ClineProvider.ts, which serves as the single source of truth for the extension's state. It manages multiple types of persistent storage (global state, workspace state, and secrets), handles state distribution to both the core extension and webview components, and coordinates state across multiple extension instances. This includes managing API configurations, task history, settings, and MCP configurations.
- webview: Anything inside the webview-ui. All the react or view's seen by the user and user interaction compone
- webview state: Managed by ExtensionStateContext in webview-ui/src/context/ExtensionStateContext.tsx, which provides React components with access to the extension's state through a context provider pattern. It maintains local state for UI components, handles real-time updates through message events, manages partial message updates, and provides methods for state modifications. The context includes extension version, messages, task history, theme, API configurations, MCP servers, marketplace catalog, and workspace file paths. It synchronizes with the core extension through VSCode's message passing system and provides type-safe access to state through a custom hook (useExtensionState).
### Core Extension State
The `ClineProvider` class manages multiple types of persistent storage:
- **Global State:** Stored across all VSCode instances. Used for settings and data that should persist globally.
- **Workspace State:** Specific to the current workspace. Used for task-specific data and settings.
- **Secrets:** Secure storage for sensitive information like API keys.
The `ClineProvider` handles the distribution of state to both the core extension and webview components. It also coordinates state across multiple extension instances, ensuring consistency.
### Webview State
The `ExtensionStateContext` in `webview-ui/src/context/ExtensionStateContext.tsx` provides React components with access to the extension's state. It uses a context provider pattern and maintains local state for UI components. The context includes:
- Extension version
- Messages
- Task history
- Theme
- API configurations
- MCP servers
- Marketplace catalog
- Workspace file paths
It synchronizes with the core extension through VSCode's message passing system and provides type-safe access to the state via a custom hook (`useExtensionState`).
## Core Extension (Cline.ts)
The Cline class is the heart of the extension, managing task execution, state persistence, and tool coordination. Each task runs in its own instance of the Cline class, ensuring isolation and proper state management.
### Task Execution Loop
The core task execution loop follows this pattern:
```typescript
class Cline {
async initiateTaskLoop(userContent: UserContent, isNewTask: boolean) {
while (!this.abort) {
// 1. Make API request and stream response
const stream = this.attemptApiRequest()
// 2. Parse and present content blocks
for await (const chunk of stream) {
switch (chunk.type) {
case "text":
// Parse into content blocks
this.assistantMessageContent = parseAssistantMessage(chunk.text)
// Present blocks to user
await this.presentAssistantMessage()
break
}
}
// 3. Wait for tool execution to complete
await pWaitFor(() => this.userMessageContentReady)
// 4. Continue loop with tool result
const recDidEndLoop = await this.recursivelyMakeClineRequests(
this.userMessageContent
)
}
}
}
```
### Message Streaming System
The streaming system handles real-time updates and partial content:
```typescript
class Cline {
async presentAssistantMessage() {
// Handle streaming locks to prevent race conditions
if (this.presentAssistantMessageLocked) {
this.presentAssistantMessageHasPendingUpdates = true
return
}
this.presentAssistantMessageLocked = true
// Present current content block
const block = this.assistantMessageContent[this.currentStreamingContentIndex]
// Handle different types of content
switch (block.type) {
case "text":
await this.say("text", content, undefined, block.partial)
break
case "tool_use":
// Handle tool execution
break
}
// Move to next block if complete
if (!block.partial) {
this.currentStreamingContentIndex++
}
}
}
```
### Tool Execution Flow
Tools follow a strict execution pattern:
```typescript
class Cline {
async executeToolWithApproval(block: ToolBlock) {
// 1. Check auto-approval settings
if (this.shouldAutoApproveTool(block.name)) {
await this.say("tool", message)
this.consecutiveAutoApprovedRequestsCount++
} else {
// 2. Request user approval
const didApprove = await askApproval("tool", message)
if (!didApprove) {
this.didRejectTool = true
return
}
}
// 3. Execute tool
const result = await this.executeTool(block)
// 4. Save checkpoint
await this.saveCheckpoint()
// 5. Return result to API
return result
}
}
```
### Error Handling & Recovery
The system includes robust error handling:
```typescript
class Cline {
async handleError(action: string, error: Error) {
// 1. Check if task was abandoned
if (this.abandoned) return
// 2. Format error message
const errorString = `Error ${action}: ${error.message}`
// 3. Present error to user
await this.say("error", errorString)
// 4. Add error to tool results
pushToolResult(formatResponse.toolError(errorString))
// 5. Cleanup resources
await this.diffViewProvider.revertChanges()
await this.browserSession.closeBrowser()
}
}
```
### API Request & Token Management
The Cline class handles API requests with built-in retry, streaming, and token management:
```typescript
class Cline {
async *attemptApiRequest(previousApiReqIndex: number): ApiStream {
// 1. Wait for MCP servers to connect
await pWaitFor(() => this.providerRef.deref()?.mcpHub?.isConnecting !== true)
// 2. Manage context window
const previousRequest = this.clineMessages[previousApiReqIndex]
if (previousRequest?.text) {
const { tokensIn, tokensOut } = JSON.parse(previousRequest.text)
const totalTokens = (tokensIn || 0) + (tokensOut || 0)
// Truncate conversation if approaching context limit
if (totalTokens >= maxAllowedSize) {
this.conversationHistoryDeletedRange = getNextTruncationRange(
this.apiConversationHistory,
this.conversationHistoryDeletedRange,
totalTokens / 2 > maxAllowedSize ? "quarter" : "half"
)
}
}
// 3. Handle streaming with automatic retry
try {
this.isWaitingForFirstChunk = true
const firstChunk = await iterator.next()
yield firstChunk.value
this.isWaitingForFirstChunk = false
// Stream remaining chunks
yield* iterator
} catch (error) {
// 4. Error handling with retry
if (isOpenRouter && !this.didAutomaticallyRetryFailedApiRequest) {
await delay(1000)
this.didAutomaticallyRetryFailedApiRequest = true
yield* this.attemptApiRequest(previousApiReqIndex)
return
}
// 5. Ask user to retry if automatic retry failed
const { response } = await this.ask(
"api_req_failed",
this.formatErrorWithStatusCode(error)
)
if (response === "yesButtonClicked") {
await this.say("api_req_retried")
yield* this.attemptApiRequest(previousApiReqIndex)
return
}
}
}
}
```
Key features:
1. **Context Window Management**
- Tracks token usage across requests
- Automatically truncates conversation when needed
- Preserves important context while freeing space
- Handles different model context sizes
2. **Streaming Architecture**
- Real-time chunk processing
- Partial content handling
- Race condition prevention
- Error recovery during streaming
3. **Error Handling**
- Automatic retry for transient failures
- User-prompted retry for persistent issues
- Detailed error reporting
- State cleanup on failure
4. **Token Tracking**
- Per-request token counting
- Cumulative usage tracking
- Cost calculation
- Cache hit monitoring
### Task State & Resumption
The Cline class provides robust task state management and resumption capabilities:
```typescript
class Cline {
async resumeTaskFromHistory() {
// 1. Load saved state
this.clineMessages = await this.getSavedClineMessages()
this.apiConversationHistory = await this.getSavedApiConversationHistory()
// 2. Handle interrupted tool executions
const lastMessage = this.apiConversationHistory[this.apiConversationHistory.length - 1]
if (lastMessage.role === "assistant") {
const toolUseBlocks = content.filter(block => block.type === "tool_use")
if (toolUseBlocks.length > 0) {
// Add interrupted tool responses
const toolResponses = toolUseBlocks.map(block => ({
type: "tool_result",
tool_use_id: block.id,
content: "Task was interrupted before this tool call could be completed."
}))
modifiedOldUserContent = [...toolResponses]
}
}
// 3. Notify about interruption
const agoText = this.getTimeAgoText(lastMessage?.ts)
newUserContent.push({
type: "text",
text: `[TASK RESUMPTION] This task was interrupted ${agoText}. It may or may not be complete, so please reassess the task context.`
})
// 4. Resume task execution
await this.initiateTaskLoop(newUserContent, false)
}
private async saveTaskState() {
// Save conversation history
await this.saveApiConversationHistory()
await this.saveClineMessages()
// Create checkpoint
const commitHash = await this.checkpointTracker?.commit()
// Update task history
await this.providerRef.deref()?.updateTaskHistory({
id: this.taskId,
ts: lastMessage.ts,
task: taskMessage.text,
// ... other metadata
})
}
}
```
Key aspects of task state management:
1. **Task Persistence**
- Each task has a unique ID and dedicated storage directory
- Conversation history is saved after each message
- File changes are tracked through Git-based checkpoints
- Terminal output and browser state are preserved
2. **State Recovery**
- Tasks can be resumed from any point
- Interrupted tool executions are handled gracefully
- File changes can be restored from checkpoints
- Context is preserved across VSCode sessions
3. **Workspace Synchronization**
- File changes are tracked through Git
- Checkpoints are created after tool executions
- State can be restored to any checkpoint
- Changes can be compared between checkpoints
4. **Error Recovery**
- Failed API requests can be retried
- Interrupted tool executions are marked
- Resources are cleaned up properly
- User is notified of state changes
## Data Flow & State Management
### Core Extension Role
The core extension (ClineProvider) acts as the single source of truth for all persistent state. It:
- Manages VSCode global state and secrets storage
- Coordinates state updates between components
- Ensures state consistency across webview reloads
- Handles task-specific state persistence
- Manages checkpoint creation and restoration
### Terminal Management
The Cline class manages terminal instances and command execution:
```typescript
class Cline {
async executeCommandTool(command: string): Promise<[boolean, ToolResponse]> {
// 1. Get or create terminal
const terminalInfo = await this.terminalManager.getOrCreateTerminal(cwd)
terminalInfo.terminal.show()
// 2. Execute command with output streaming
const process = this.terminalManager.runCommand(terminalInfo, command)
// 3. Handle real-time output
let result = ""
process.on("line", (line) => {
result += line + "\n"
if (!didContinue) {
sendCommandOutput(line)
} else {
this.say("command_output", line)
}
})
// 4. Wait for completion or user feedback
let completed = false
process.once("completed", () => {
completed = true
})
await process
// 5. Return result
if (completed) {
return [false, `Command executed.\n${result}`]
} else {
return [
false,
`Command is still running in the user's terminal.\n${result}\n\nYou will be updated on the terminal status and new output in the future.`
]
}
}
}
```
Key features:
1. **Terminal Instance Management**
- Multiple terminal support
- Terminal state tracking (busy/inactive)
- Process cooldown monitoring
- Output history per terminal
2. **Command Execution**
- Real-time output streaming
- User feedback handling
- Process state monitoring
- Error recovery
### Browser Session Management
The Cline class handles browser automation through Puppeteer:
```typescript
class Cline {
async executeBrowserAction(action: BrowserAction): Promise<BrowserActionResult> {
switch (action) {
case "launch":
// 1. Launch browser with fixed resolution
await this.browserSession.launchBrowser()
return await this.browserSession.navigateToUrl(url)
case "click":
// 2. Handle click actions with coordinates
return await this.browserSession.click(coordinate)
case "type":
// 3. Handle keyboard input
return await this.browserSession.type(text)
case "close":
// 4. Clean up resources
return await this.browserSession.closeBrowser()
}
}
}
```
Key aspects:
1. **Browser Control**
- Fixed 900x600 resolution window
- Single instance per task lifecycle
- Automatic cleanup on task completion
- Console log capture
2. **Interaction Handling**
- Coordinate-based clicking
- Keyboard input simulation
- Screenshot capture
- Error recovery
## Conclusion
This guide provides a comprehensive overview of the Cline extension architecture, with special focus on state management, data persistence, and code organization. Following these patterns ensures robust feature implementation with proper state handling across the extension's components.
Remember:
- Always persist important state in the extension
- The core extension exists in the src/ folder
- Use proper typing for all state and messages
- Handle errors and edge cases
- Test state persistence across webview reloads
- Follow the established patterns for consistency
- Place new code in appropriate directories
- Maintain clear separation of concerns
- Install dependencies in correct package.json
## Contributing
Contributions to the Cline extension are welcome! Please follow these guidelines:
When adding new tools or API providers, follow the existing patterns in the `src/integrations/` and `src/api/providers/` directories, respectively. Ensure that your code is well-documented and includes appropriate error handling.
The `.clineignore` file allows users to specify files and directories that Cline should not access. When implementing new features, respect the `.clineignore` rules and ensure that your code does not attempt to read or modify ignored files.
+1 -1
View File
@@ -1 +1 @@
* @saoudrizwan @ocasta181 @NightTrek @pashpashpash @dcbartlett
* @saoudrizwan @ocasta181 @NightTrek @pashpashpash
+3
View File
@@ -28,7 +28,10 @@ updates:
patterns:
- "*"
ignore:
# Ignore CRA and related packages that often have false positives
- dependency-name: "react-scripts"
- dependency-name: "@testing-library/*"
- dependency-name: "web-vitals"
- dependency-name: "*"
update-types:
- "version-update:semver-major"
@@ -26,65 +26,39 @@ import sys
CHANGELOG_PATH = os.environ.get("CHANGELOG_PATH", "CHANGELOG.md")
VERSION = os.environ['VERSION']
PREV_VERSION = os.environ.get("PREV_VERSION", "")
NEW_CONTENT = os.environ.get("NEW_CONTENT", "")
# Find the section for the specified version
version_index = -1
version_pattern = f"## {VERSION}\n"
bracketed_version_pattern = f"## [{VERSION}]\n"
header_end_index = 0
print(f"latest version: {VERSION}")
def overwrite_changelog_section(changelog_text: str, new_content: str):
# Find the section for the specified version
version_pattern = f"## {VERSION}\n"
bracketed_version_pattern = f"## [{VERSION}]\n"
prev_version_pattern = f"## [{PREV_VERSION}]\n"
print(f"latest version: {VERSION}")
print(f"prev_version: {PREV_VERSION}")
def fetch_changelog_header(changelog_text: str):
global version_pattern, version_index, bracketed_version_pattern, header_end_index
header = ""
print(f"Starting fetch_changelog_header")
# Try both unbracketed and bracketed version patterns
version_index = changelog_text.find(version_pattern)
if version_index == -1:
print("Version not found, trying bracketed version pattern")
version_index = changelog_text.find(bracketed_version_pattern)
if version_index == -1:
print("Bracketed version not found, adding new version header")
# If version not found, add it at the top (after the first line)
first_newline = changelog_text.find('\n')
print(f"First newline index: {first_newline}")
if first_newline == -1:
print("No newline found, prepending new version header")
# If no newline found, just prepend
header = f"## [{VERSION}]\n\n"
header = f"{changelog_text[:first_newline + 1]}\n## [{VERSION}]\n\n"
return f"## [{VERSION}]\n\n{changelog_text}"
return f"{changelog_text[:first_newline + 1]}## [{VERSION}]\n\n{changelog_text[first_newline + 1:]}"
else:
# Using bracketed version
version_pattern = bracketed_version_pattern
header = changelog_text[:version_index]
else:
header = changelog_text[:version_index]
header_end_index = len(header)
return header
def generate_changelog_section(changelog_text: str, new_content: str):
global version_pattern, version_index, header_end_index
print(f"Starting generate_changelog_section")
print(f"Version index: {version_index}")
print(f"Version pattern: {version_pattern} {len(version_pattern)}")
print(f"Header end index: {header_end_index}")
prev_version_pattern = "## ["
prev_version_index = changelog_text[header_end_index:].find(prev_version_pattern)
print(f"Previous version index: {prev_version_index}")
notes_start_index = version_index + len(version_pattern)
notes_end_index = changelog_text.find(prev_version_pattern, notes_start_index) if PREV_VERSION and prev_version_pattern in changelog_text else len(changelog_text)
if new_content:
print("Detected new content, overwriting existing changeset")
return f"{new_content}\n" + changelog_text[prev_version_index:]
return changelog_text[:notes_start_index] + f"{new_content}\n" + changelog_text[notes_end_index:]
else:
print("No new content provided, reformatting existing changeset")
changeset_lines = changelog_text[header_end_index:prev_version_index].split("\n")
print(f"Changeset lines: {changeset_lines}")
changeset_lines = changelog_text[notes_start_index:notes_end_index].split("\n")
# Ensure we have at least 2 lines before removing them
if len(changeset_lines) < 2:
print("Warning: Changeset content has fewer than 2 lines")
@@ -92,22 +66,11 @@ def generate_changelog_section(changelog_text: str, new_content: str):
else:
# Remove the first two lines from the regular changeset format, ex: \n### Patch Changes
parsed_lines = "\n".join(changeset_lines[2:])
# Reconstruct the changelog with the new content
updated_changelog = parsed_lines + changelog_text[prev_version_index:]
updated_changelog = changelog_text[:notes_start_index] + parsed_lines + changelog_text[notes_end_index:]
# Ensure version number is bracketed
updated_changelog = updated_changelog.replace(f"## {VERSION}", f"## [{VERSION}]")
return updated_changelog
def overwrite_changelog_section(changelog_text: str, new_content: str):
print(f"Starting overwrite_changelog_section")
header = fetch_changelog_header(changelog_text)
body = generate_changelog_section(changelog_text, new_content)
print(f"Header: {header}")
return header + body
try:
print(f"Reading changelog from: {CHANGELOG_PATH}")
with open(CHANGELOG_PATH, 'r') as f:
@@ -122,7 +85,7 @@ try:
print("New changelog content:")
print("----------------------------------------------------------------------------------")
# print(new_changelog)
print(new_changelog)
print("----------------------------------------------------------------------------------")
print(f"Writing updated changelog back to: {CHANGELOG_PATH}")
+162
View File
@@ -0,0 +1,162 @@
name: Changeset Release
run-name: Changeset Release ${{ github.actor != 'cline-bot' && '- Create PR' || '- Update Changelog' }}
permissions:
contents: write
pull-requests: write
on:
workflow_dispatch:
pull_request:
types: [closed, opened, labeled]
env:
REPO_PATH: ${{ github.repository }}
GIT_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || 'main' }}
jobs:
# Job 1: Create version bump PR when changesets are merged to main
changeset-pr-version-bump:
if: >
( github.event_name == 'pull_request' &&
github.event.pull_request.merged == true &&
github.event.pull_request.base.ref == 'main' &&
github.actor != 'cline-bot' ) ||
github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Git Checkout
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
with:
fetch-depth: 0
ref: ${{ env.GIT_REF }}
- name: Setup Node.js
uses: actions/setup-node@b39b52d1213e96004bfcb1c61a8a6fa8ab84f3e8 # v4
with:
node-version: 20
cache: "npm"
- name: Install Dependencies
run: npm run install:all
# Check if there are any new changesets to process
- name: Check for changesets
id: check-changesets
run: |
NEW_CHANGESETS=$(find .changeset -name "*.md" ! -name "README.md" | wc -l | tr -d ' ')
echo "Changesets diff with previous version: $NEW_CHANGESETS"
echo "new_changesets=$NEW_CHANGESETS" >> $GITHUB_OUTPUT
# Create version bump PR using changesets/action if there are new changesets
- name: Changeset Pull Request
if: steps.check-changesets.outputs.new_changesets != '0'
id: changesets
uses: changesets/action@e9cc34b540dd3ad1b030c57fd97269e8f6ad905a # v1
with:
commit: "changeset version bump"
title: "Changeset version bump"
version: npm run version-packages # This performs the changeset version bump
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Job 2: Process version bump PR created by cline-bot
changeset-pr-edit-approve:
name: Auto approve and merge Bump version PRs
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
if: >
github.event_name == 'pull_request' &&
github.event.pull_request.base.ref == 'main' &&
github.actor == 'cline-bot' &&
contains(github.event.pull_request.title, 'Changeset version bump')
steps:
- name: Determine checkout ref
id: checkout-ref
run: |
echo "Event action: ${{ github.event.action }}"
echo "Actor: ${{ github.actor }}"
echo "Head ref: ${{ github.head_ref }}"
echo "PR SHA: ${{ github.event.pull_request.head.sha }}"
if [[ "${{ github.event.action }}" == "opened" && "${{ github.actor }}" == "cline-bot" ]]; then
echo "Using branch ref: ${{ github.head_ref }}"
echo "git_ref=${{ github.head_ref }}" >> $GITHUB_OUTPUT
else
echo "Using SHA ref: ${{ github.event.pull_request.head.sha }}"
echo "git_ref=${{ github.event.pull_request.head.sha }}" >> $GITHUB_OUTPUT
fi
- name: Checkout Repo
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
ref: ${{ steps.checkout-ref.outputs.git_ref }}
# Get current and previous versions to edit changelog entry
- name: Get version
id: get_version
run: |
VERSION=$(git show HEAD:package.json | jq -r '.version')
echo "version=$VERSION" >> $GITHUB_OUTPUT
PREV_VERSION=$(git show origin/main:package.json | jq -r '.version')
echo "prev_version=$PREV_VERSION" >> $GITHUB_OUTPUT
echo "version=$VERSION"
echo "prev_version=$PREV_VERSION"
# Update CHANGELOG.md with proper format
- name: Update Changelog Format
if: ${{ !contains(github.event.pull_request.labels.*.name, 'changelog-ready') }}
env:
VERSION: ${{ steps.get_version.outputs.version }}
PREV_VERSION: ${{ steps.get_version.outputs.prev_version }}
run: python .github/scripts/overwrite_changeset_changelog.py
# Commit and push changelog updates
- name: Push Changelog updates
if: ${{ !contains(github.event.pull_request.labels.*.name, 'changelog-ready') }}
run: |
git config user.name "cline-bot"
git config user.email github-actions@github.com
echo "Running git add and commit..."
git add CHANGELOG.md
git commit -m "Updating CHANGELOG.md format"
git status
echo "--------------------------------------------------------------------------------"
echo "Pushing to remote..."
echo "--------------------------------------------------------------------------------"
git push
# Add label to indicate changelog has been formatted
- name: Add changelog-ready label
if: ${{ !contains(github.event.pull_request.labels.*.name, 'changelog-ready') }}
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ['changelog-ready']
});
# Auto-approve PR only after it has been labeled
- name: Auto approve PR
if: contains(github.event.pull_request.labels.*.name, 'changelog-ready')
uses: hmarr/auto-approve-action@de8bf34d0402c38aa2c8346973342b2cb02c4435 # v4
with:
review-message: "I'm approving since it's a bump version PR"
# Auto-merge PR
- name: Automerge on PR
if: false # Needs enablePullRequestAutoMerge in repo settings to work contains(github.event.pull_request.labels.*.name, 'changelog-ready')
run: gh pr merge --auto --merge ${{ github.event.pull_request.number }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
with:
fetch-depth: 0
ref: ${{ github.event.pull_request.head.sha }}
+26 -36
View File
@@ -6,7 +6,7 @@ on:
release-type:
description: "Choose release type (release or pre-release)"
required: true
default: "pre-release"
default: "release"
type: choice
options:
- pre-release
@@ -19,11 +19,11 @@ permissions:
pull-requests: write
jobs:
# test:
# uses: ./.github/workflows/test.yml
test:
uses: ./.github/workflows/test.yml
publish:
# needs: test
needs: test
name: Publish Extension
runs-on: ubuntu-latest
environment: publish
@@ -75,8 +75,8 @@ jobs:
VERSION=v${{ steps.get_version.outputs.version }}
echo "tag=$VERSION" >> $GITHUB_OUTPUT
echo "Tagging with $VERSION"
# git tag "$VERSION"
# git push origin "$VERSION"
git tag "$VERSION"
git push origin "$VERSION"
- name: Package and Publish Extension
env:
@@ -87,39 +87,29 @@ jobs:
vsce package --out "cline-${{ steps.get_version.outputs.version }}.vsix"
if [ "${{ github.event.inputs.release-type }}" = "pre-release" ]; then
# npm run publish:marketplace:prerelease
npm run publish:marketplace:prerelease
echo "Successfully published pre-release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
else
# npm run publish:marketplace
npm run publish:marketplace
echo "Successfully published release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
fi
# - name: Create Changelog Entry
# id: changesets
# uses: changesets/action@v1
# env:
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create Changelog Entry
id: changesets
env:
VERSION: ${{ steps.get_version.outputs.version }}
run: |
python .github/scripts/overwrite_changeset_changelog.py
- name: Get Changelog Entry
id: changelog
uses: mindsers/changelog-reader-action@v2
with:
version: ${{ steps.get_version.outputs.version }}
# - name: Create GitHub Release
# uses: softprops/action-gh-release@v1
# - name: Get Changelog Entry
# id: changelog
# uses: mindsers/changelog-reader-action@v2
# with:
# tag_name: ${{ steps.create_tag.outputs.tag }}
# files: "*.vsix"
# # body: ${{ steps.fetch-changelog.outputs.content }}
# generate_release_notes: true
# prerelease: ${{ github.event.inputs.release-type == 'pre-release' }}
# env:
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# # This expects a standard Keep a Changelog format
# # "latest" means it will read whichever is the most recent version
# # set in "## [1.2.3] - 2025-01-28" style
# version: latest
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ steps.create_tag.outputs.tag }}
files: "*.vsix"
# body: ${{ steps.changelog.outputs.content }}
generate_release_notes: true
prerelease: ${{ github.event.inputs.release-type == 'pre-release' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -3
View File
@@ -7,6 +7,4 @@ tmp
.DS_Store
pnpm-lock.yaml
.clineignore
pnpm-lock.yaml
+1 -5
View File
@@ -11,11 +11,7 @@
"request": "launch",
"args": ["--extensionDevelopmentPath=${workspaceFolder}"],
"outFiles": ["${workspaceFolder}/dist/**/*.js"],
"preLaunchTask": "${defaultBuildTask}",
"env": {
"IS_DEV": "true",
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}"
}
"preLaunchTask": "${defaultBuildTask}"
}
]
}
+4 -59
View File
@@ -5,7 +5,7 @@
"tasks": [
{
"label": "watch",
"dependsOn": ["npm: build:webview", "npm: dev:webview", "npm: watch:tsc", "npm: watch:esbuild"],
"dependsOn": ["npm: build:webview", "npm: watch:tsc", "npm: watch:esbuild"],
"presentation": {
"reveal": "never"
},
@@ -23,47 +23,7 @@
"label": "npm: build:webview",
"presentation": {
"group": "watch",
"reveal": "never",
"close": true
},
"options": {
"env": {
"IS_DEV": "true"
}
}
},
{
"type": "npm",
"script": "dev:webview",
"group": "build",
"problemMatcher": [
{
"pattern": [
{
"regexp": ".",
"file": 1,
"location": 2,
"message": 3
}
],
"background": {
"activeOnStart": true,
"beginsPattern": ".",
"endsPattern": "."
}
}
],
"isBackground": true,
"label": "npm: dev:webview",
"presentation": {
"group": "watch",
"reveal": "never",
"close": true
},
"options": {
"env": {
"IS_DEV": "true"
}
"reveal": "never"
}
},
{
@@ -75,8 +35,7 @@
"label": "npm: watch:esbuild",
"presentation": {
"group": "watch",
"reveal": "never",
"close": true
"reveal": "never"
}
},
{
@@ -88,8 +47,7 @@
"label": "npm: watch:tsc",
"presentation": {
"group": "watch",
"reveal": "never",
"close": true
"reveal": "never"
}
},
{
@@ -107,19 +65,6 @@
"label": "tasks: watch-tests",
"dependsOn": ["npm: watch", "npm: watch-tests"],
"problemMatcher": []
},
{
"label": "stop",
"command": "echo ${input:terminate}",
"type": "shell"
}
],
"inputs": [
{
"id": "terminate",
"type": "command",
"command": "workbench.action.tasks.terminate",
"args": "terminateAll"
}
]
}
+1
View File
@@ -23,6 +23,7 @@ demo.gif
# Ignore all webview-ui files except the build directory (https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/frameworks/hello-world-react-cra/.vscodeignore)
webview-ui/src/**
webview-ui/public/**
webview-ui/scripts/**
webview-ui/index.html
webview-ui/README.md
webview-ui/package.json
-67
View File
@@ -1,74 +1,7 @@
# Changelog
## [3.7.1]
- Tests
- Tests
- Tests
## [3.7.0]
- Cline now displays selectable options when asking questions or presenting a plan, saving you from having to type out responses!
- Add support for a `.clinerules/` directory to load multiple files at once (thanks @ryo-ma!)
- Prevent Cline from reading extremely large files into context that would overload context window
- Improve checkpoints loading performance and display warning for large projects not suited for checkpoints
- Add SambaNova API provider (thanks @saad-noodleseed!)
- Add VPC endpoint option for AWS Bedrock profiles (thanks @minorunara!)
- Add DeepSeek-R1 to AWS Bedrock (thanks @watany-dev!)
## [3.6.5]
- Add 'Delete all Task History' button to History view
- Add toggle to disable model switching between Plan/Act modes in Settings (new users default to disabled)
- Add temperature option to OpenAI Compatible
- Add Kotlin support to tree-sitter parser (thanks @fumiya-kume!)
## [3.6.3]
- Improve QwQ support for Alibaba (thanks @meglinge!) and OpenRouter
- Improve diff edit prompting to prevent immediately reverting to write_to_file when a model uses search patterns that don't match anything in the file
- Fix bug where new checkpoints system would revert file changes when switching between tasks
- Fix issue with incorrect token count for some OpenAI compatible providers
## [3.6.0]
- Add Cline API as a provider option, allowing new users to sign up and get started with Cline for free
- Optimize checkpoints with branch-per-task strategy, reducing storage required and first task load times
- Fix problem with Plan/Act toggle keyboard shortcut not working in Windows (thanks @yt3trees!)
- Add new Gemini models to GCP Vertex (thanks @shohei-ihaya!) and Claude models AskSage (thanks @swhite24!)
- Improve OpenRouter/Cline error reporting
## [3.5.1]
- Add timeout option to MCP servers
- Add Gemini Flash models to Vertex provider (thanks @jpaodev!)
- Add prompt caching support for AWS Bedrock provider (thanks @buger!)
- Add AskSage provider (thanks @swhite24!)
## [3.5.0]
- Add 'Enable extended thinking' option for Claude 3.7 Sonnet, with ability to set different budgets for Plan and Act modes
- Add support for rich MCP responses with automatic image previews, website thumbnails, and WolframAlpha visualizations
- Add language preference option in Advanced Settings
- Add xAI Provider Integration with support for all Grok models (thanks @andrewmonostate!)
- Fix issue with Linux XDG pointing to incorrect path for Document folder (thanks @jonatkinson!)
## [3.4.10]
- Add support for GPT-4.5 preview model
## [3.4.9]
- Add toggle to let users opt-in to anonymous telemetry and error reporting
## [3.4.6]
- Add support for Claude 3.7 Sonnet
## [3.4.0]
- Introducing MCP Marketplace! You can now discover and install the best MCP servers right from within the extension, with new servers added regularly
- Add mermaid diagram support in Plan mode! You can now see visual representations of mermaid code blocks in chat, and click on them to see an expanded view
- Use more visual checkpoints indicators after editing files & running commands
- Create a checkpoint at the beginning of each task to easily revert to the initial state
- Add 'Terminal' context mention to reference the active terminal's contents
+2 -2
View File
@@ -1,5 +1,5 @@
<div align="center"><sub>
English | <a href="https://github.com/cline/cline/blob/main/locales/es/README.md" target="_blank">Español</a> | <a href="https://github.com/cline/cline/blob/main/locales/de/README.md" target="_blank">Deutsch</a> | <a href="https://github.com/cline/cline/blob/main/locales/ja/README.md" target="_blank">日本語</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-cn/README.md" target="_blank">简体中文</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-tw/README.md" target="_blank">繁體中文</a> | <a href="https://github.com/cline/cline/blob/main/locales/ko/README.md" target="_blank">한국어</a>
English | <a href="https://github.com/cline/cline/blob/main/locales/es/README.md" target="_blank">Español</a> | <a href="https://github.com/cline/cline/blob/main/locales/de/README.md" target="_blank">Deutsch</a> | <a href="https://github.com/cline/cline/blob/main/locales/ja/README.md" target="_blank">日本語</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-cn/README.md" target="_blank">简体中文</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-tw/README.md" target="_blank">繁體中文</a>
</sub></div>
# Cline \#1 on OpenRouter
@@ -32,7 +32,7 @@ English | <a href="https://github.com/cline/cline/blob/main/locales/es/README.md
Meet Cline, an AI assistant that can use your **CLI** a**N**d **E**ditor.
Thanks to [Claude 3.7 Sonnet's agentic coding capabilities](https://www.anthropic.com/claude/sonnet), Cline can handle complex software development tasks step-by-step. With tools that let him create & edit files, explore large projects, use the browser, and execute terminal commands (after you grant permission), he can assist you in ways that go beyond code completion or tech support. Cline can even use the Model Context Protocol (MCP) to create new tools and extend his own capabilities. While autonomous AI scripts traditionally run in sandboxed environments, this extension provides a human-in-the-loop GUI to approve every file change and terminal command, providing a safe and accessible way to explore the potential of agentic AI.
Thanks to [Claude 3.5 Sonnet's agentic coding capabilities](https://www-cdn.anthropic.com/fed9cc193a14b84131812372d8d5857f8f304c52/Model_Card_Claude_3_Addendum.pdf), Cline can handle complex software development tasks step-by-step. With tools that let him create & edit files, explore large projects, use the browser, and execute terminal commands (after you grant permission), he can assist you in ways that go beyond code completion or tech support. Cline can even use the Model Context Protocol (MCP) to create new tools and extend his own capabilities. While autonomous AI scripts traditionally run in sandboxed environments, this extension provides a human-in-the-loop GUI to approve every file change and terminal command, providing a safe and accessible way to explore the potential of agentic AI.
1. Enter your task and add images to convert mockups into functional apps or fix bugs with screenshots.
2. Cline starts by analyzing your file structure & source code ASTs, running regex searches, and reading relevant files to get up to speed in existing projects. By carefully managing what information is added to context, Cline can provide valuable assistance even for large, complex projects without overwhelming the context window.
+99 -1
View File
@@ -1 +1,99 @@
See [https://cline.bot/privacy](https://cline.bot/privacy) for our privacy policy.
# Cline Privacy Policy
Cline Bot Inc. ("Cline," "we," "our," and/or "us") values the privacy of individuals who use our VS Code extension and related services (collectively, our "Services"). This privacy policy explains how we collect, use, and disclose information from users of our Services.
## Key Points
- Cline operates entirely client-side as a VS Code extension
- No code or data is collected, stored, or transmitted to Cline's servers
- Your data is only sent to your chosen AI provider (e.g., Anthropic, OpenAI) when you explicitly request assistance
- All processing happens locally on your machine
- API keys are stored securely in VS Code's built-in settings storage
## Information We Process
### A. Information You Provide
- **API Keys**: When you choose to use certain AI model providers (OpenRouter, Anthropic, OpenAI, etc.), you provide API keys. These are stored securely and locally in your VS Code settings.
- **Communications**: If you contact us directly (e.g., via Discord or email), we may receive information like your name, email address, and message contents.
### B. Information Processing
Cline functions solely as a client-side VS Code extension that facilitates communication between your editor and your chosen AI model provider:
1. **File Contents**:
- Only sent to your chosen AI provider when you explicitly request assistance
- Never stored or transmitted to Cline's servers
- Only the specific files/content you select are included
2. **Terminal Commands**:
- Processed entirely locally on your machine
- Require explicit user confirmation before execution
- No command history is transmitted to Cline
3. **Browser Integration**:
- Screenshots and console logs are processed locally
- Temporary data is cleared after task completion
## Data Security
1. **Local-Only Processing**:
- All operations happen on your local machine
- No central servers or data collection
- No telemetry or usage statistics gathered
- No account creation required
2. **API Key Security**:
- Stored using VS Code's secure settings storage system
- Never transmitted to Cline's servers
- You can remove/modify keys at any time
3. **User Control**:
- Explicit approval required for file changes
- Terminal commands require confirmation
- Browser actions need explicit permission
- You control which AI provider to use
## Communication with AI Providers
When you request assistance:
1. Selected content is sent directly to your chosen AI provider
2. No data passes through Cline's servers
3. Provider's own privacy policy applies to this communication:
- [Anthropic Privacy Policy](https://www.anthropic.com/privacy)
- [OpenAI Privacy Policy](https://openai.com/privacy)
- [OpenRouter Privacy Policy](https://openrouter.ai/privacy)
## Error Handling & Debugging
- Error logs are processed locally
- No automatic error reporting to Cline
- You control what information to include when reporting issues
## Children's Privacy
We do not knowingly collect, maintain, or use personal information from children under 18 years of age, and no part of our Service(s) is directed to children. If you learn that a child has provided us with personal information in violation of this Privacy Policy, then you may alert us at support@cline.bot.
## Changes to Privacy Policy
We will post any changes to this policy on our GitHub repository. Significant changes will be announced in our Discord community.
## Security Concerns & Auditing
- Cline is open source and available for security audit
- Our client-side architecture ensures no central point of data collection
- You can inspect exactly what data is being sent to AI providers
- Enterprise users can implement additional access controls through VS Code
## Contact Us
For privacy-related questions or concerns:
- Open an issue on our [GitHub repository](https://github.com/cline/cline)
- Join our [Discord community](https://discord.gg/cline)
- Email: support@cline.bot
+1 -1
View File
@@ -28,7 +28,7 @@ Welcome to the Cline documentation - your comprehensive guide to using and exten
- **Interested in contributing?** We welcome your input:
- Feel free to submit a pull request
- [Contribution Guidelines](../CONTRIBUTING.md)
- [Contribution Guidelines](CONTRIBUTING.md)
## Additional Resources
-43
View File
@@ -1,43 +0,0 @@
# Cline Extension Architecture
This directory contains architectural documentation for the Cline VSCode extension.
## Extension Architecture Diagram
The [extension-architecture.mmd](./extension-architecture.mmd) file contains a Mermaid diagram showing the high-level architecture of the Cline extension. The diagram illustrates:
1. **Core Extension**
- Extension entry point and main classes
- State management through VSCode's global state and secrets storage
- Core business logic in the Cline class
2. **Webview UI**
- React-based user interface
- State management through ExtensionStateContext
- Component hierarchy
3. **Storage**
- Task-specific storage for history and state
- Git-based checkpoint system for file changes
4. **Data Flow**
- Core extension data flow between components
- Webview UI data flow
- Bidirectional communication between core and webview
## Viewing the Diagram
To view the diagram:
1. Install a Mermaid diagram viewer extension in VSCode
2. Open extension-architecture.mmd
3. Use the extension's preview feature to render the diagram
You can also view the diagram on GitHub, which has built-in Mermaid rendering support.
## Color Scheme
The diagram uses a high-contrast color scheme for better visibility:
- Pink (#ff0066): Global state and secrets storage components
- Blue (#0066ff): Extension state context
- Green (#00cc66): Cline provider
- All components use white text for maximum readability
@@ -1,41 +0,0 @@
graph TB
subgraph VSCode Extension Host
subgraph Core Extension
ExtensionEntry[Extension Entry<br/>src/extension.ts]
ClineProvider[ClineProvider<br/>src/core/webview/ClineProvider.ts]
ClineClass[Cline Class<br/>src/core/Cline.ts]
GlobalState[VSCode Global State]
SecretsStorage[VSCode Secrets Storage]
end
subgraph Webview UI
WebviewApp[React App<br/>webview-ui/src/App.tsx]
ExtStateContext[ExtensionStateContext<br/>webview-ui/src/context/ExtensionStateContext.tsx]
ReactComponents[React Components]
end
subgraph Storage
TaskStorage[Task Storage<br/>Per-Task Files & History]
CheckpointSystem[Git-based Checkpoints]
end
end
%% Core Extension Data Flow
ExtensionEntry --> ClineProvider
ClineProvider --> ClineClass
ClineClass --> GlobalState
ClineClass --> SecretsStorage
ClineClass --> TaskStorage
ClineClass --> CheckpointSystem
%% Webview Data Flow
WebviewApp --> ExtStateContext
ExtStateContext --> ReactComponents
%% Bidirectional Communication
ClineProvider <-->|postMessage| ExtStateContext
style GlobalState fill:#ff0066,stroke:#333,stroke-width:2px,color:#ffffff
style SecretsStorage fill:#ff0066,stroke:#333,stroke-width:2px,color:#ffffff
style ExtStateContext fill:#0066ff,stroke:#333,stroke-width:2px,color:#ffffff
style ClineProvider fill:#00cc66,stroke:#333,stroke-width:2px,color:#ffffff
-21
View File
@@ -128,27 +128,6 @@ Cline's system prompt, on the other hand, is not user-editable ([here's where yo
- Focus on Desired Outcomes: Describe the results you want, not the specific steps.
- Test and Iterate: Experiment to find what works best for your workflow.
### Support for Loading Files from the `.clinerules/` Directory
All files under the `.clinerules/` directory are recursively loaded, and their contents are merged into clineRulesFileInstructions.
#### Example 1:
```
.clinerules/
├── .local-clinerules
└── .project-clinerules
```
#### Example 2:
```
.clinerules/
├── .clinerules-nextjs
├── .clinerules-serverside
└── tests/
├── .pytest-clinerules
└── .jest-clinerules
```
## Prompting Cline 💬
**Prompting is how you communicate your needs for a given task in the back-and-forth chat with Cline.** Cline understands natural language, so write conversationally.
-1
View File
@@ -52,7 +52,6 @@ const copyWasmFiles = {
"java",
"php",
"swift",
"kotlin",
]
languages.forEach((lang) => {
-47
View File
@@ -1,47 +0,0 @@
# ميثاق المساهمين
## تعهدنا
نحن المساهمون والقائمون على هذا المشروع، نتعهد بتوفير بيئة مفتوحة ومرحبة، ونجعل المشاركة في مشروعنا ومجتمعنا تجربة خالية من التحرش للجميع، بغض النظر عن العمر، أو حجم الجسم، أو الإعاقة، أو العرق، أو الخصائص الجنسية، أو الهوية الجنسية والتعبير عنها، أو مستوى الخبرة، أو التعليم، أو الوضع الاجتماعي والاقتصادي، أو الجنسية، أو المظهر الشخصي، أو الدين، أو الهوية الجنسية والتوجه الجنسي.
## معاييرنا
أمثلة على السلوك الذي يساهم في خلق بيئة إيجابية تشمل:
- استخدام لغة ترحيبية وشاملة
- احترام وجهات النظر والخبرات المختلفة
- تقبل النقد البناء برحابة صدر
- التركيز على ما هو الأفضل للمجتمع
- إظهار التعاطف تجاه أعضاء المجتمع الآخرين
أمثلة على السلوك غير المقبول من قبل المشاركين تشمل:
- استخدام لغة أو صور جنسية والاهتمام الجنسي غير المرغوب فيه أو التحرش الجنسي
- التصيد، والتعليقات المهينة/المسيئة، والهجمات الشخصية أو السياسية
- التحرش العلني أو الخاص
- نشر معلومات الآخرين الخاصة، مثل العنوان الفعلي أو الإلكتروني، دون إذن صريح
- أي سلوك آخر يمكن اعتباره غير لائق في بيئة مهنية
## مسؤولياتنا
يتحمل القائمون على المشروع مسؤولية توضيح معايير السلوك المقبول، ومن المتوقع أن يتخذوا إجراءات تصحيحية مناسبة وعادلة استجابة لأي حالات سلوك غير مقبول.
يحق للقائمين على المشروع إزالة أو تعديل أو رفض التعليقات والالتزامات والتعليمات البرمجية وتعديلات wiki والمشكلات والمساهمات الأخرى التي لا تتماشى مع مدونة قواعد السلوك هذه، أو حظر أي مساهم بشكل مؤقت أو دائم بسبب سلوكيات أخرى يعتبرونها غير لائقة أو مهددة أو مسيئة أو ضارة، كما أنهم يتحملون مسؤولية ذلك.
## النطاق
تنطبق مدونة قواعد السلوك هذه داخل مساحات المشروع وفي الأماكن العامة عندما يمثل الفرد المشروع أو مجتمعه. تتضمن أمثلة تمثيل مشروع أو مجتمع استخدام عنوان بريد إلكتروني رسمي للمشروع، أو النشر عبر حساب رسمي على وسائل التواصل الاجتماعي، أو العمل كممثل معين في حدث عبر الإنترنت أو خارجه. يمكن للقائمين على المشروع تحديد وتوضيح تمثيل المشروع بشكل أكبر.
## التنفيذ
يمكن الإبلاغ عن حالات السلوك المسيء أو التحرش أو السلوك غير المقبول عن طريق الاتصال بفريق المشروع على hi@cline.bot. ستتم مراجعة جميع الشكاوى والتحقيق فيها وستؤدي إلى استجابة تعتبر ضرورية ومناسبة للظروف. يلتزم فريق المشروع بالحفاظ على السرية فيما يتعلق بالمبلغ عن الحادث. يمكن نشر مزيد من التفاصيل حول سياسات التنفيذ المحددة بشكل منفصل.
قد يواجه القائمون على المشروع الذين لا يتبعون أو يفرضون مدونة قواعد السلوك بحسن نية تداعيات مؤقتة أو دائمة على النحو الذي يحدده الأعضاء الآخرون في قيادة المشروع.
## الإسناد
تم اقتباس مدونة قواعد السلوك هذه من [تعهد المساهم][homepage]، الإصدار 1.4، متاح على https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
[homepage]: https://www.contributor-covenant.org
للحصول على إجابات للأسئلة الشائعة حول مدونة قواعد السلوك هذه، راجع https://www.contributor-covenant.org/faq
-93
View File
@@ -1,93 +0,0 @@
# المساهمة في Cline
نحن سعداء لاهتمامك بالمساهمة في Cline. سواء كنت تصلح خطأً أو تضيف ميزة أو تحسن الوثائق لدينا، فإن كل مساهمة تجعل Cline أذكى! للحفاظ على مجتمعنا نابضًا بالحياة وترحيبيًا، يجب على جميع الأعضاء الالتزام بـ [مدونة قواعد السلوك](CODE_OF_CONDUCT.md) لدينا.
## الإبلاغ عن الأخطاء أو المشكلات
تساعد تقارير الأخطاء على جعل Cline أفضل للجميع! قبل إنشاء مشكلة جديدة، يرجى [البحث عن المشكلات الموجودة](https://github.com/cline/cline/issues) لتجنب الازدواجية. عندما تكون جاهزًا للإبلاغ عن خطأ، انتقل إلى [صفحة المشكلات](https://github.com/cline/cline/issues/new/choose) حيث ستجد قالبًا لمساعدتك في ملء المعلومات ذات الصلة.
<blockquote class='warning-note'>
🔐 <b>مهم:</b> إذا اكتشفت ثغرة أمنية، فيرجى استخدام <a href="https://github.com/cline/cline/security/advisories/new">أداة الأمان على Github للإبلاغ عنها بشكل خاص</a>.
</blockquote>
## تحديد ما يجب العمل عليه
تبحث عن مساهمة أولى جيدة؟ تحقق من المشكلات المميزة بـ ["good first issue"](https://github.com/cline/cline/labels/good%20first%20issue) أو ["help wanted"](https://github.com/cline/cline/labels/help%20wanted). تم تحديد هذه المشكلات خصيصًا للمساهمين الجدد والمجالات التي نرحب فيها بالمساعدة!
نرحب أيضًا بالمساهمات في [الوثائق](https://github.com/cline/cline/tree/main/docs) لدينا! سواء كان تصحيح أخطاء إملائية، أو تحسين الأدلة الحالية، أو إنشاء محتوى تعليمي جديد - نود بناء مستودع موارد مدفوع من المجتمع يساعد الجميع على الاستفادة القصوى من Cline. يمكنك البدء بالغوص في `/docs` والبحث عن مجالات تحتاج إلى تحسين.
إذا كنت تخطط للعمل على ميزة أكبر، فيرجى إنشاء [طلب ميزة](https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop) أولاً حتى نتمكن من مناقشة ما إذا كان ذلك يتماشى مع رؤية Cline.
## إعداد التطوير
1. **إضافات VS Code**
- عند فتح المشروع، سيطالبك VS Code بتثبيت الإضافات الموصى بها
- هذه الإضافات مطلوبة للتطوير - يرجى قبول جميع مطالبات التثبيت
- إذا تجاهلت المطالبات، يمكنك تثبيتها يدويًا من لوحة الإضافات
2. **التطوير المحلي**
- قم بتشغيل `npm run install:all` لتثبيت التبعيات
- قم بتشغيل `npm run test` لتشغيل الاختبارات محليًا
- قبل تقديم طلب السحب، قم بتشغيل `npm run format:fix` لتنسيق التعليمات البرمجية الخاصة بك
## كتابة وتقديم التعليمات البرمجية
يمكن لأي شخص المساهمة بالتعليمات البرمجية في Cline، لكننا نطلب منك اتباع هذه الإرشادات لضمان دمج مساهماتك بسلاسة:
1. **احتفظ بطلبات السحب مركزة**
- قيد طلبات السحب بميزة واحدة أو إصلاح خطأ
- قسم التغييرات الأكبر إلى طلبات سحب أصغر ومتصلة
- قسم التغييرات إلى التزامات منطقية يمكن مراجعتها بشكل مستقل
2. **جودة التعليمات البرمجية**
- قم بتشغيل `npm run lint` للتحقق من نمط التعليمات البرمجية
- قم بتشغيل `npm run format` لتنسيق التعليمات البرمجية تلقائيًا
- يجب أن تجتاز جميع طلبات السحب عمليات التحقق المستمر التي تشمل كلاً من التنضيد والتنسيق
- تعامل مع أي تحذيرات أو أخطاء ESLint قبل التقديم
- اتبع أفضل ممارسات TypeScript والحفاظ على سلامة النوع
3. **الاختبار**
- أضف اختبارات للميزات الجديدة
- قم بتشغيل `npm test` للتأكد من اجتياز جميع الاختبارات
- قم بتحديث الاختبارات الحالية إذا كانت تغييراتك تؤثر عليها
- تضمين كل من اختبارات الوحدة واختبارات التكامل حيثما كان ذلك مناسبًا
4. **إدارة الإصدار مع Changesets**
- أنشئ changeset لأي تغييرات واجهة المستخدم باستخدام `npm run changeset`
- اختر زيادة الإصدار المناسبة:
- `major` للتغييرات الكبيرة (1.0.0 → 2.0.0)
- `minor` للميزات الجديدة (1.0.0 → 1.1.0)
- `patch` لإصلاحات الأخطاء (1.0.0 → 1.0.1)
- اكتب رسائل changeset واضحة ووصفية تشرح التأثير
- لا تتطلب التغييرات في الوثائق فقط changesets
5. **إرشادات الالتزام (Commit Guidelines)**
- اكتب رسائل التزام واضحة وواصفة
- استخدم تنسيق الالتزام التقليدي (مثل: "feat:", "fix:", "docs:")
- أشر إلى القضايا ذات الصلة في الالتزامات باستخدام #رقم-القضية
6. **قبل الإرسال**
- قم بإعادة دمج فرعك مع أحدث إصدار من الفرع الرئيسي
- تأكد من أن الفرع الخاص بك يُبنى بنجاح
- تحقق من اجتياز جميع الاختبارات
- راجع التغييرات الخاصة بك للتأكد من عدم وجود تعليمات تصحيح الأخطاء أو سجلات وحدة التحكم
7. **وصف طلب السحب (Pull Request Description)**
- صف بوضوح ما تقوم به التغييرات
- قم بتضمين خطوات لاختبار التغييرات
- أدرج أي تغييرات غير متوافقة
- أضف لقطات شاشة للتغييرات في واجهة المستخدم
## اتفاقية المساهمة
من خلال إرسال طلب سحب، فإنك توافق على أن مساهماتك سيتم ترخيصها بنفس ترخيص المشروع ([Apache 2.0](LICENSE)).
تذكر: المساهمة في Cline لا تقتصر فقط على كتابة الكود - إنها تتعلق بأن تكون جزءًا من مجتمع يُشكل مستقبل التطوير بمساعدة الذكاء الاصطناعي. لنبنِ شيئًا رائعًا معًا! 🚀
-189
View File
@@ -1,189 +0,0 @@
<div align="center"><sub>
العربية | <a href="https://github.com/cline/cline/blob/main/locales/es/README.md" target="_blank">الإسبانية</a> | <a href="https://github.com/cline/cline/blob/main/locales/de/README.md" target="_blank">الألمانية</a> | <a href="https://github.com/cline/cline/blob/main/locales/ja/README.md" target="_blank">اليابانية</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-cn/README.md" target="_blank">الصينية المبسطة</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-tw/README.md" target="_blank">الصينية التقليدية</a> | <a href="https://github.com/cline/cline/blob/main/locales/pt-BR/README.md" target="_blank">البرتغالية</a>
</sub></div>
# Cline \#1 على OpenRouter
<p align="center">
<img src="https://media.githubusercontent.com/media/cline/cline/main/assets/docs/demo.gif" width="100%" />
</p>
<div align="center">
<table>
<tbody>
<td align="center">
<a href="https://marketplace.visualstudio.com/items?itemName=saoudrizwan.claude-dev" target="_blank"><strong>تنزيل من متجر VS</strong></a>
</td>
<td align="center">
<a href="https://discord.gg/cline" target="_blank"><strong>Discord</strong></a>
</td>
<td align="center">
<a href="https://www.reddit.com/r/cline/" target="_blank"><strong>r/cline</strong></a>
</td>
<td align="center">
<a href="https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop" target="_blank"><strong>طلبات الميزات</strong></a>
</td>
<td align="center">
<a href="https://docs.cline.bot/getting-started/getting-started-new-coders" target="_blank"><strong>البدء</strong></a>
</td>
</tbody>
</table>
</div>
التقى Cline، مساعد الذكاء الاصطناعي الذي يمكنه استخدام **سطر الأوامر** و **محرر النصوص** الخاص بك.
بفضل [قدرات Claude 3.7 Sonnet على التعليمات البرمجية الوكيلة](https://www.anthropic.com/claude/sonnet)، يمكن لـ Cline التعامل مع مهام تطوير البرامج المعقدة خطوة بخطوة. مع الأدوات التي تسمح له بإنشاء وتعديل الملفات، واستكشاف المشاريع الكبيرة، واستخدام المتصفح، وتنفيذ أوامر الطرفية (بعد منحك الإذن)، يمكنه مساعدتك بطرق تتجاوز إكمال الكود أو الدعم الفني. يمكن لـ Cline أيضًا استخدام بروتوكول سياق النموذج (MCP) لإنشاء أدوات جديدة وتوسيع قدراته الخاصة. في حين تعمل النصوص البرمجية الآلية المستقلة تقليديًا في بيئات محاصرة، توفر هذه الإضافة واجهة رسومية لموافقة المستخدم على كل تغيير في الملف وأمر طرفية، مما يوفر طريقة آمنة وسهلة الاستخدام لاستكشاف إمكانات الذكاء الاصطناعي الوكيل.
1. أدخل مهمتك وأضف الصور لتحويل المحاكاة إلى تطبيقات وظيفية أو إصلاح الأخطاء مع لقطات الشاشة.
2. يبدأ Cline بتحليل هيكل الملفات الخاصة بك وشجرة التعريف المصدرية، وإجراء عمليات بحث regex، وقراءة الملفات ذات الصلة للاطلاع على المشاريع الحالية. من خلال إدارة المعلومات التي يتم إضافتها إلى السياق بعناية، يمكن لـ Cline تقديم مساعدة قيمة حتى للمشاريع الكبيرة والمعقدة دون إرهاق نافذة السياق.
3. بمجرد حصول Cline على المعلومات التي يحتاجها، يمكنه:
- إنشاء وتعديل الملفات + مراقبة أخطاء Linter/Compiler أثناء السير، مما يسمح له بإصلاح المشكلات مثل الواردات المفقودة وأخطاء البناء النحوي بمفرده.
- تنفيذ الأوامر مباشرة في الطرفية الخاصة بك ومراقبة إخراجها أثناء العمل، مما يسمح له على سبيل المثال بالاستجابة لمشكلات خادم التطوير بعد تعديل ملف.
- بالنسبة لمهام تطوير الويب، يمكن لـ Cline إطلاق الموقع في متصفح بلا رأس، والنقر، وكتابة النص، والتمرير، والتقاط لقطات الشاشة + سجلات وحدة التحكم، مما يسمح له بإصلاح أخطاء وقت التشغيل والأخطاء البصرية.
4. عند اكتمال المهمة، سيقدم Cline النتيجة لك مع أمر طرفية مثل `open -a "Google Chrome" index.html`، والذي تقوم بتشغيله بنقرة زر.
> [!TIP]
> استخدم اختصار `CMD/CTRL + Shift + P` لفتح لوحة الأوامر واكتب "Cline: Open In New Tab" لفتح الإضافة كعلامة تبويب في محرر النصوص الخاص بك. يتيح لك هذا استخدام Cline جنبًا إلى جنب مع مستكشف الملفات الخاص بك، ورؤية كيف يغير مساحة العمل الخاصة بك بوضوح أكبر.
---
<img align="right" width="340" src="https://github.com/user-attachments/assets/3cf21e04-7ce9-4d22-a7b9-ba2c595e88a4">
### استخدم أي واجهة برمجة تطبيقات ونموذج
يدعم Cline مقدمي واجهات برمجة التطبيقات مثل OpenRouter و Anthropic و OpenAI و Google Gemini و AWS Bedrock و Azure و GCP Vertex. يمكنك أيضًا تكوين أي واجهة برمجة تطبيقات متوافقة مع OpenAI، أو استخدام نموذج محلي من خلال LM Studio/Ollama. إذا كنت تستخدم OpenRouter، فستقوم الإضافة بجلب قائمة النماذج الأحدث الخاصة بهم، مما يسمح لك باستخدام أحدث النماذج بمجرد توفرها.
تتتبع الإضافة أيضًا إجمالي الرموز والاستخدام الخاص بواجهة برمجة التطبيقات لدورة المهمة بأكملها وطلبات فردية، مما يبقيك على اطلاع بالإنفاق في كل خطوة.
<!-- Transparent pixel to create line break after floating image -->
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
<img align="left" width="370" src="https://github.com/user-attachments/assets/81be79a8-1fdb-4028-9129-5fe055e01e76">
### تشغيل الأوامر في الطرفية
بفضل [تحديثات تكامل الشل الجديدة في VSCode v1.93](https://code.visualstudio.com/updates/v1_93#_terminal-shell-integration-api)، يمكن لـ Cline تنفيذ الأوامر مباشرة في الطرفية الخاصة بك وتلقي الإخراج. يسمح له هذا بأداء مجموعة واسعة من المهام، من تثبيت الحزم وتشغيل سكربتات البناء إلى نشر التطبيقات، وإدارة قواعد البيانات، وتنفيذ الاختبارات، وذلك بالتكيف مع بيئة التطوير الخاصة بك وسلسلة الأدوات للقيام بالعمل على النحو الصحيح.
بالنسبة للعمليات الطويلة المدى مثل خوادم التطوير، استخدم زر "المتابعة أثناء التشغيل" للسماح لـ Cline بالاستمرار في المهمة بينما يعمل الأمر في الخلفية. أثناء عمل Cline، سيتم إخباره بأي إخراج طرفية جديد على الطريق، مما يسمح له بالاستجابة للمشكلات التي قد تنشأ، مثل أخطاء وقت الإنشاء عند تعديل الملفات.
<!-- Transparent pixel to create line break after floating image -->
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
<img align="right" width="400" src="https://github.com/user-attachments/assets/c5977833-d9b8-491e-90f9-05f9cd38c588">
### إنشاء وتعديل الملفات
يمكن لـ Cline إنشاء وتعديل الملفات مباشرة في محرر النصوص الخاص بك، وعرض الاختلافات. يمكنك تعديل أو إلغاء تغييرات Cline مباشرة في محرر الاختلافات، أو تقديم ملاحظات في الدردشة حتى تكون راضيًا عن النتيجة. يراقب Cline أيضًا أخطاء Linter/Compiler (الواردات المفقودة، أخطاء البناء النحوي، إلخ) حتى يتمكن من إصلاح المشكلات التي تنشأ أثناء السير بمفرده.
يتم تسجيل جميع التغييرات التي أجراها Cline في جدول زمني للملف، مما يوفر طريقة سهلة لتتبع وإلغاء التعديلات إذا لزم الأمر.
<!-- Transparent pixel to create line break after floating image -->
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
<img align="left" width="370" src="https://github.com/user-attachments/assets/bc2e85ba-dfeb-4fe6-9942-7cfc4703cbe5">
### استخدم المتصفح
مع قدرة [استخدام الكمبيوتر](https://www.anthropic.com/news/3-5-models-and-computer-use) الجديدة لـ Claude 3.5 Sonnet، يمكن لـ Cline إطلاق متصفح، والنقر على العناصر، وكتابة النص، والتمرير، والتقاط لقطات الشاشة وسجلات وحدة التحكم في كل خطوة. يسمح له هذا بالتصحيح التفاعلي، واختبار نهاية إلى نهاية، وحتى الاستخدام العام للويب! يمنحه هذا الاستقلالية لإصلاح الأخطاء البصرية وأخطاء وقت التشغيل دون الحاجة إلى نسخ ولصق سجلات الأخطاء بنفسك.
حاول طلب من Cline "اختبار التطبيق"، وشاهده يشغل أمرًا مثل `npm run dev`، ويطلق خادم التطوير المحلي في متصفح، ويجري سلسلة من الاختبارات للتأكد من أن كل شيء يعمل. [شاهد عرضًا توضيحيًا هنا.](https://x.com/sdrzn/status/1850880547825823989)
<!-- Transparent pixel to create line break after floating image -->
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
<img align="right" width="350" src="https://github.com/user-attachments/assets/ac0efa14-5c1f-4c26-a42d-9d7c56f5fadd">
### "إضافة أداة التي..."
شكراً لـ [بروتوكول سياق النموذج](https://github.com/modelcontextprotocol)، يمكن لـ Cline توسيع قدراته من خلال الأدوات المخصصة. بينما يمكنك استخدام [الخوادم التي أنشأها المجتمع](https://github.com/modelcontextprotocol/servers)، يمكن لـ Cline بدلاً من ذلك إنشاء أدوات وتثبيتها مصممة خصيصًا لتناسب سير عملك. ما عليك سوى أن تطلب من Cline "إضافة أداة"، وسيتولى كل شيء، من إنشاء خادم MCP جديد إلى تثبيته في الامتداد. تصبح هذه الأدوات المخصصة بعد ذلك جزءًا من مجموعة أدوات Cline، جاهزة للاستخدام في المهام المستقبلية.
- **"أضف أداة تجلب تذاكر Jira"**: استرجع تذاكر AC وقم بتشغيل Cline
- **"أضف أداة تدير AWS EC2s"**: تحقق من مقاييس الخادم وقم بتوسيع أو تقليص عدد الحالات
- **"أضف أداة تجلب أحدث حوادث PagerDuty"**: استرجع التفاصيل واطلب من Cline إصلاح الأخطاء
<!-- بكسل شفاف لإنشاء فاصل سطر بعد الصورة العائمة -->
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
<img align="left" width="360" src="https://github.com/user-attachments/assets/7fdf41e6-281a-4b4b-ac19-020b838b6970">
### إضافة السياق
**`@url`**: الصق رابط URL ليقوم الامتداد بجلبه وتحويله إلى Markdown، مفيد عندما تريد تزويد Cline بأحدث الوثائق
**`@problems`**: أضف أخطاء وتحذيرات بيئة العمل ('لوحة المشكلات') ليتمكن Cline من إصلاحها
**`@file`**: يضيف محتويات ملف حتى لا تضطر إلى إهدار طلبات API بالموافقة على قراءة الملف (+ البحث في الملفات)
**`@folder`**: يضيف جميع ملفات المجلد دفعة واحدة لتسريع سير العمل بشكل أكبر
<!-- بكسل شفاف لإنشاء فاصل سطر بعد الصورة العائمة -->
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
<img align="right" width="350" src="https://github.com/user-attachments/assets/140c8606-d3bf-41b9-9a1f-4dbf0d4c90cb">
### نقاط التحقق: المقارنة والاستعادة
أثناء عمل Cline على مهمة، يأخذ الامتداد لقطة من بيئة العمل في كل خطوة. يمكنك استخدام زر "Compare" لرؤية الفرق بين اللقطة وبيئة العمل الحالية، وزر "Restore" للعودة إلى تلك النقطة.
على سبيل المثال، عند العمل مع خادم ويب محلي، يمكنك استخدام "استعادة بيئة العمل فقط" لاختبار إصدارات مختلفة من تطبيقك بسرعة، ثم استخدام "استعادة المهمة وبيئة العمل" عندما تجد الإصدار الذي تريد المتابعة منه. يتيح لك ذلك استكشاف أساليب مختلفة بأمان دون فقدان التقدم.
<!-- بكسل شفاف لإنشاء فاصل سطر بعد الصورة العائمة -->
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
## المساهمة
للمساهمة في المشروع، ابدأ بـ [دليل المساهمة](CONTRIBUTING.md) لتعلم الأساسيات. يمكنك أيضًا الانضمام إلى [خادم Discord](https://discord.gg/cline) للدردشة مع المساهمين الآخرين في قناة `#contributors`. إذا كنت تبحث عن عمل بدوام كامل، تحقق من الوظائف المتاحة على [صفحة التوظيف](https://cline.bot/join-us)!
<details>
<summary>تعليمات التطوير المحلي</summary>
1. استنساخ المستودع _(يتطلب [git-lfs](https://git-lfs.com/))_:
```bash
git clone https://github.com/cline/cline.git
```
2. افتح المشروع في VSCode:
```bash
code cline
```
3. قم بتثبيت التبعيات اللازمة للامتداد وواجهة الويب:
```bash
npm run install:all
```
4. قم بالتشغيل بالضغط على `F5` (أو من `Run` -> `Start Debugging`) لفتح نافذة VSCode جديدة مع تحميل الامتداد. (قد تحتاج إلى تثبيت [إضافة esbuild problem matchers](https://marketplace.visualstudio.com/items?itemName=connor4312.esbuild-problem-matchers) إذا واجهت مشكلات في بناء المشروع.)
</details>
<details>
<summary>إنشاء طلب سحب (Pull Request)</summary>
1. قبل إنشاء PR، قم بإنشاء إدخال للتغييرات:
```bash
npm run changeset
```
سيطلب منك تحديد:
- نوع التغيير (رئيسي، ثانوي، إصلاح)
- `رئيسي` → تغييرات غير متوافقة (1.0.0 → 2.0.0)
- `ثانوي` → ميزات جديدة (1.0.0 → 1.1.0)
- `إصلاح` → إصلاحات للأخطاء (1.0.0 → 1.0.1)
- وصف التغييرات التي قمت بها
2. قم بحفظ التغييرات وملف `.changeset` الذي تم إنشاؤه
3. ادفع فرعك وأنشئ PR على GitHub. سيقوم CI بـ:
- تشغيل الاختبارات والفحوصات
- سيقوم Changesetbot بإنشاء تعليق يوضح تأثير الإصدار
- عند الدمج مع الفرع الرئيسي، سيقوم Changesetbot بإنشاء PR لحزم الإصدار
- عند دمج PR لحزم الإصدار، سيتم نشر إصدار جديد
</details>
## الرخصة
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
+2 -2
View File
@@ -28,7 +28,7 @@
Lernen Sie Cline kennen, einen KI-Assistenten, der Ihre **CLI** u**N**d **E**ditor nutzen kann.
Dank der [agentischen Codierungsfähigkeiten von Claude 3.7 Sonnet](https://www.anthropic.com/claude/sonnet) kann Cline komplexe Softwareentwicklungsaufgaben Schritt für Schritt bewältigen. Mit Werkzeugen, die ihm das Erstellen und Bearbeiten von Dateien, das Erkunden großer Projekte, die Nutzung des Browsers und das Ausführen von Terminalbefehlen (nach Ihrer Genehmigung) ermöglichen, kann er Ihnen auf eine Weise helfen, die über die Codevervollständigung oder technischen Support hinausgeht. Cline kann sogar das Model Context Protocol (MCP) verwenden, um neue Werkzeuge zu erstellen und seine eigenen Fähigkeiten zu erweitern. Während autonome KI-Skripte traditionell in sandboxed Umgebungen laufen, bietet diese Erweiterung eine Mensch-in-der-Schleife-GUI, um jede Dateiänderung und jeden Terminalbefehl zu genehmigen, was eine sichere und zugängliche Möglichkeit bietet, das Potenzial agentischer KI zu erkunden.
Dank der [agentischen Codierungsfähigkeiten von Claude 3.5 Sonnet](https://www-cdn.anthropic.com/fed9cc193a14b84131812372d8d5857f8f304c52/Model_Card_Claude_3_Addendum.pdf) kann Cline komplexe Softwareentwicklungsaufgaben Schritt für Schritt bewältigen. Mit Werkzeugen, die ihm das Erstellen und Bearbeiten von Dateien, das Erkunden großer Projekte, die Nutzung des Browsers und das Ausführen von Terminalbefehlen (nach Ihrer Genehmigung) ermöglichen, kann er Ihnen auf eine Weise helfen, die über die Codevervollständigung oder technischen Support hinausgeht. Cline kann sogar das Model Context Protocol (MCP) verwenden, um neue Werkzeuge zu erstellen und seine eigenen Fähigkeiten zu erweitern. Während autonome KI-Skripte traditionell in sandboxed Umgebungen laufen, bietet diese Erweiterung eine Mensch-in-der-Schleife-GUI, um jede Dateiänderung und jeden Terminalbefehl zu genehmigen, was eine sichere und zugängliche Möglichkeit bietet, das Potenzial agentischer KI zu erkunden.
1. Geben Sie Ihre Aufgabe ein und fügen Sie Bilder hinzu, um Mockups in funktionale Apps zu konvertieren oder Fehler mit Screenshots zu beheben.
2. Cline beginnt mit der Analyse Ihrer Dateistruktur und Quellcode-ASTs, führt Regex-Suchen durch und liest relevante Dateien, um sich in bestehenden Projekten zurechtzufinden. Durch sorgfältiges Management der hinzugefügten Informationen kann Cline wertvolle Unterstützung auch bei großen, komplexen Projekten bieten, ohne das Kontextfenster zu überladen.
@@ -158,5 +158,5 @@ Um zum Projekt beizutragen, beginnen Sie mit unserem [Beitragsleitfaden](CONTRIB
## Lizenz
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
[Apache 2.0 © 2024 Cline Bot Inc.](./LICENSE)
+2 -2
View File
@@ -28,7 +28,7 @@
Conozca a Cline, un asistente de IA que puede usar su **CLI** y **E**ditor.
Gracias a las [habilidades de codificación agencial de Claude 3.7 Sonnet](https://www.anthropic.com/claude/sonnet), Cline puede abordar tareas complejas de desarrollo de software paso a paso. Con herramientas que le permiten crear y editar archivos, explorar grandes proyectos, usar el navegador y ejecutar comandos de terminal (con su aprobación), puede ayudarle de una manera que va más allá de la autocompletación de código o el soporte técnico. Cline incluso puede usar el Model Context Protocol (MCP) para crear nuevas herramientas y expandir sus propias capacidades. Mientras que los scripts de IA autónomos tradicionalmente se ejecutan en entornos aislados, esta extensión ofrece una GUI con un humano en el bucle para aprobar cada cambio de archivo y comando de terminal, proporcionando una forma segura y accesible de explorar el potencial de la IA agencial.
Gracias a las [habilidades de codificación agencial de Claude 3.5 Sonnet](https://www-cdn.anthropic.com/fed9cc193a14b84131812372d8d5857f8f304c52/Model_Card_Claude_3_Addendum.pdf), Cline puede abordar tareas complejas de desarrollo de software paso a paso. Con herramientas que le permiten crear y editar archivos, explorar grandes proyectos, usar el navegador y ejecutar comandos de terminal (con su aprobación), puede ayudarle de una manera que va más allá de la autocompletación de código o el soporte técnico. Cline incluso puede usar el Model Context Protocol (MCP) para crear nuevas herramientas y expandir sus propias capacidades. Mientras que los scripts de IA autónomos tradicionalmente se ejecutan en entornos aislados, esta extensión ofrece una GUI con un humano en el bucle para aprobar cada cambio de archivo y comando de terminal, proporcionando una forma segura y accesible de explorar el potencial de la IA agencial.
1. Ingrese su tarea y agregue imágenes para convertir maquetas en aplicaciones funcionales o solucionar errores con capturas de pantalla.
2. Cline comenzará analizando su estructura de archivos y ASTs de código fuente, realizando búsquedas Regex y leyendo archivos relevantes para orientarse en proyectos existentes. Al gestionar cuidadosamente la información agregada, Cline puede proporcionar asistencia valiosa incluso en proyectos grandes y complejos sin sobrecargar la ventana de contexto.
@@ -158,4 +158,4 @@ Para contribuir al proyecto, comience con nuestra [guía de contribución](CONTR
## Licencia
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
[Apache 2.0 © 2024 Cline Bot Inc.](./LICENSE)
+2 -2
View File
@@ -28,7 +28,7 @@
Clineは、**CLI**と**エディター**を使用できるAIアシスタントです。
[Claude 3.7 Sonnetのエージェント的コーディング機能](https://www.anthropic.com/claude/sonnet)のおかげで、Clineは複雑なソフトウェア開発タスクをステップバイステップで処理できます。ファイルの作成と編集、大規模プロジェクトの探索、ブラウザの使用、ターミナルコマンドの実行(許可後)などのツールを使用して、コード補完や技術サポートを超えた支援を提供します。Clineは、Model Context Protocol (MCP)を使用して新しいツールを作成し、自身の機能を拡張することもできます。自律的なAIスクリプトは通常サンドボックス環境で実行されますが、この拡張機能はファイル変更やターミナルコマンドを承認するための人間インターフェースを提供し、エージェント的AIの可能性を安全かつアクセスしやすい方法で探求できます。
[Claude 3.5 Sonnetのエージェント的コーディング機能](https://www-cdn.anthropic.com/fed9cc193a14b84131812372d8d5857f8f304c52/Model_Card_Claude_3_Addendum.pdf)のおかげで、Clineは複雑なソフトウェア開発タスクをステップバイステップで処理できます。ファイルの作成と編集、大規模プロジェクトの探索、ブラウザの使用、ターミナルコマンドの実行(許可後)などのツールを使用して、コード補完や技術サポートを超えた支援を提供します。Clineは、Model Context Protocol (MCP)を使用して新しいツールを作成し、自身の機能を拡張することもできます。自律的なAIスクリプトは通常サンドボックス環境で実行されますが、この拡張機能はファイル変更やターミナルコマンドを承認するための人間インターフェースを提供し、エージェント的AIの可能性を安全かつアクセスしやすい方法で探求できます。
1. タスクを入力し、モックアップを機能するアプリに変換したり、スクリーンショットでバグを修正したりします。
2. Clineは、ファイル構造とソースコードASTの分析、正規表現検索の実行、関連ファイルの読み取りから始め、既存プロジェクトに精通します。コンテキストに追加される情報を慎重に管理することで、大規模で複雑なプロジェクトでもコンテキストウィンドウを圧倒することなく貴重な支援を提供できます。
@@ -158,4 +158,4 @@ Clineがタスクを進める中で、拡張機能は各ステップでワーク
## ライセンス
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
[Apache 2.0 © 2024 Cline Bot Inc.](./LICENSE)
-47
View File
@@ -1,47 +0,0 @@
# 기여자 행동 강령
## 서약
우리는 개방적이고 환영하는 환경을 조성하기 위해 노력하며, 기여자 및 유지 관리자로서 모든 사람이 차별과 괴롭힘 없이 프로젝트와 커뮤니티에 참여할 수 있도록 최선을 다할 것을 서약합니다. 이는 연령, 체형, 장애, 민족성, 성적 특성, 성 정체성 및 표현, 경험 수준, 교육 수준, 사회·경제적 지위, 국적, 외모, 인종, 종교, 성 정체성과 성적 지향에 관계없이 모든 사람에게 적용됩니다.
## 행동 기준
긍정적인 환경을 조성하기 위한 바람직한 행동의 예시:
- 환영하고 포용적인 언어 사용하기
- 서로 다른 관점과 경험을 존중하기
- 건설적인 비판을 우아하게 수용하기
- 커뮤니티에 최선이 되는 것에 집중하기
- 다른 커뮤니티 구성원들에 대한 공감 보여주기
참여자가 해서는 안 되는 행동의 예시:
- 성적인 언어와 이미지 사용, 원치 않는 성적 관심이나 접근
- 트롤링, 모욕적/경멸적인 댓글, 개인적 또는 정치적 공격
- 공개적 또는 사적인 괴롭힘
- 상대방의 동의 없이 개인정보(실제 주소나 전자 주소 등) 공개하기
- 전문적 환경에서 부적절하다고 여겨질 수 있는 기타 행위
## 책임
프로젝트 유지 관리자는 허용 가능한 행동 기준을 명확히 설명할 책임이 있으며, 부적절한 행동이 발생할 경우 적절하고 공정한 시정 조치를 취해야 합니다.
프로젝트 유지 관리자는 본 행동 강령에 부합하지 않는 댓글, 커밋, 코드, 위키 수정, 이슈 및 기타 기여를 삭제, 수정 또는 거부할 권리와 책임이 있으며, 부적절하다고 판단되는 행동(위협적이거나, 공격적이거나, 해로운 행위 등)을 한 기여자를 일시적 또는 영구적으로 차단할 권리를 가집니다.
## 범위
이 행동 강령은 프로젝트 공간과 개인이 프로젝트나 커뮤니티를 대표하는 공개 공간에서 모두 적용됩니다. 프로젝트 또는 커뮤니티를 대표하는 예로는 공식 프로젝트 이메일 주소 사용, 공식 소셜 미디어 계정을 통한 게시, 온라인 또는 오프라인 행사에서 지정된 대표자로 활동하는 경우 등이 포함됩니다. 프로젝트의 대표성은 프로젝트 유지 관리자가 추가로 정의하고 명확히 할 수 있습니다.
## 집행
학대, 괴롭힘 또는 기타 용납할 수 없는 행동은 프로젝트 팀에 hi@cline.bot을 통해 신고 할 수 있습니다. 모든 신고는 검토 및 조사되며, 상황에 따라 필요하고 적절한 조치가 취해질 것입니다. 프로젝트 팀은 사건 신고자의 신원을 보호할 의무가 있습니다. 특정 시행 정책에 대한 추가 세부 사항은 별도로 게시될 수 있습니다.
행동 강령을 성실히 준수하거나 집행하지 않는 프로젝트 유지관리자는 프로젝트 리더십의 구성원에 의해 일시적 또는 영구적인 제재를 받을 수 있습니다.
## 출처
이 행동 강령은 [Contributor Covenant][homepage] 버전 1.4에서 수정되었으며, https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 에서 확인할 수 있습니다.
[homepage]: https://www.contributor-covenant.org
이 행동 강령에 대한 일반적인 질문에 대한 답변은 https://www.contributor-covenant.org/faq 를 참조하시기 바랍니다.
-92
View File
@@ -1,92 +0,0 @@
# Cline에 기여하기
Cline에 기여하는 것에 관심을 가져주셔서 감사합니다! 버그 수정, 기능 추가, 문서 개선 등 모든 기여는 Cline을 더욱 스마트하게 만드는 데 기여합니다. 활기차고 환영하는 커뮤니티를 유지하기 위해 모든 구성원은 [행동 강령](CODE_OF_CONDUCT.md)을 준수해야 합니다.
## 버그와 문제 보고
버그 보고는 Cline을 모두에게 더 나은 것으로 만드는 데 도움이 됩니다! 새로운 이슈를 생성하기 전에, 중복을 피하기 위해 [기존 이슈를 검색](https://github.com/cline/cline/issues)해 주세요. 버그를 보고할 준비가 되었다면, [이슈 페이지](https://github.com/cline/cline/issues/new/choose)로 이동하여 관련 정보를 작성하기 위한 템플릿을 사용해 주세요.
<blockquote class='warning-note'>
🔐 <b>중요:</b> 보안 취약점을 발견한 경우, <a href="https://github.com/cline/cline/security/advisories/new">GitHub 보안 도구를 사용하여 비공개로 보고</a>해 주세요.
</blockquote>
## 작업 내용 결정하기
첫 기여를 찾고 계신가요? ["good first issue"](https://github.com/cline/cline/labels/good%20first%20issue)나 ["help wanted"](https://github.com/cline/cline/labels/help%20wanted) 라벨이 붙은 이슈를 확인해 보세요. 이러한 이슈들은 새로운 기여자를 위해 특별히 선정된 작업으로, 도움이 필요한 영역이 표시되어 있습니다!
또한, [문서](https://github.com/cline/cline/tree/main/docs)에 대한 기여도 환영합니다! 오타 수정, 기존 가이드 개선, 새로운 교육 콘텐츠 작성 등, 커뮤니티 주도의 리소스 저장소를 구축하는 데 여러분의 도움이 필요합니다. `/docs`를 살펴보고 개선이 필요한 부분을 찾아보세요.
큰 기능에 대해 작업할 계획이 있다면, 먼저 [기능 요청](https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop)을 생성하여 이것이 Cline의 비전과 부합하는지 논의하는 것이 좋습니다.
## 개발 환경 설정
1. **VS Code 확장 프로그램**
- 프로젝트를 열면 VS Code가 권장 확장 프로그램 설치를 안내합니다
- 개발을 위해 이 확장 프로그램들이 필요하므로, 설치 안내를 수락해 주세요.
- 프롬프트를 닫은 경우 확장 프로그램 패널에서 수동으로 설치할 수 있습니다
2. **로컬 개발**
- `npm run install:all`을 실행하여 의존성을 설치합니다
- `npm run test`를 실행하여 로컬에서 테스트를 실행합니다
- PR을 제출하기 전에 `npm run format:fix`를 실행하여 코드를 포맷팅합니다
## 코드 작성과 제출
누구나 Cline에 코드를 기여할 수 있지만, 기여가 원활하게 통합되도록 다음 가이드라인을 따라주세요:
1. **Pull Request 집중하기**
- PR은 단일 기능 또는 버그 수정으로 제한해 주세요
- 큰 변경사항은 작은 관련 PR로 분할해 주세요
- 논리적으로 독립적인 커밋 단위로 나누어 리뷰가 용이하도록 구성하세요.
2. **코드 품질**
- `npm run lint`를 실행하여 코드 스타일을 체크합니다
- `npm run format`을 실행하여 코드를 자동으로 포맷팅합니다
- 모든 PR은 린팅과 포맷팅을 포함한 CI 체크를 통과해야 합니다
- 제출 전에 ESLint 경고나 에러를 모두 해결해 주세요
- TypeScript 모범 사례를 따르고, 타입 안전성을 유지해 주세요
3. **테스트**
- 새로운 기능에는 테스트를 추가해 주세요
- `npm test`를 실행하여 모든 테스트가 통과하는지 확인해 주세요
- 변경사항이 기존 테스트에 영향을 미치는 경우 해당 테스트를 업데이트해 주세요
- 적절한 경우 단위 테스트와 통합 테스트를 모두 포함해 주세요
4. **Changesets를 활용한 버전 관리**
- 사용자에게 영향을 미치는 변경 사항이 있는 경우, `npm run changeset`을 실행하여 changeset을 생성해 주세요
- 적절한 버전 증가 옵션을 선택하세요:
- `major` 호환되지 않는 변경 (1.0.0 → 2.0.0)
- `minor` 새로운 기능 추가 (1.0.0 → 1.1.0)
- `patch` 버그 수정 (1.0.0 → 1.0.1)
- 영향을 설명하는 명확한 변경사항 메시지를 작성해 주세요
- 문서 변경만 있는 경우 changeset이 필요하지 않습니다
5. **커밋 가이드라인**
- 명확하고 설명적인 커밋 메시지를 작성해 주세요
- 컨벤셔널 커밋 형식(예: "feat:", "fix:", "docs:")을 사용해 주세요
- 커밋에서 관련 이슈를 #issue-number를 사용하여 참조해 주세요
6. **제출 전 확인사항**
- 최신 main에 브랜치를 리베이스해 주세요
- 브랜치가 정상적으로 빌드되는지 확인해 주세요
- 모든 테스트가 통과하는지 다시 확인해 주세요
- 디버그 코드나 콘솔 로그가 없는지 변경사항을 확인해 주세요
7. **Pull Request 설명**
- 변경 내용을 명확하게 설명해 주세요
- 변경사항을 테스트하는 방법을 포함해 주세요
- 호환되지 않는 변경 사항이 있다면 목록으로 작성해주세요
- UI 변경이 있는 경우, 스크린샷을 추가해 주세요
## 기여 동의서
Pull Request를 제출함으로써, 귀하의 기여가 프로젝트와 동일한 라이선스([Apache 2.0](/LICENSE)) 에 따라 제공됨에 동의하는 것입니다.
기억하세요: Cline에 기여하는 것은 코드를 작성하는 것뿐만 아니라, AI 지원 개발의 미래를 형성하는 커뮤니티의 일원이 되는 것입니다. 함께 멋진 것을 만들어봅시다! 🚀
-172
View File
@@ -1,172 +0,0 @@
# Cline - 최고의 OpenRouter
<p align="center">
<img src="https://media.githubusercontent.com/media/cline/cline/main/assets/docs/demo.gif" width="100%" />
</p>
<div align="center">
<table>
<tbody>
<td align="center">
<a href="https://marketplace.visualstudio.com/items?itemName=saoudrizwan.claude-dev" target="_blank"><strong>VS Marketplace에서 다운로드</strong></a>
</td>
<td align="center">
<a href="https://discord.gg/cline" target="_blank"><strong>Discord</strong></a>
</td>
<td align="center">
<a href="https://www.reddit.com/r/cline/" target="_blank"><strong>r/cline</strong></a>
</td>
<td align="center">
<a href="https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop" target="_blank"><strong>기능 요청</strong></a>
</td>
<td align="center">
<a href="https://cline.bot/join-us" target="_blank"><strong>채용 정보</strong></a>
</td>
</tbody>
</table>
</div>
Cline을 만나보세요, **CLI** 및 **에디터**를 활용할 수 있는 AI 어시스턴트입니다.
[Claude 3.7 Sonnet의 에이전트형 코딩 기능](https://www.anthropic.com/claude/sonnet) 덕분에, Cline은 복잡한 소프트웨어 개발 작업을 단계별로 처리할 수 있습니다. 파일 생성과 편집, 대규모 프로젝트 탐색, 브라우저 사용, 터미널 명령 실행(권한 허가 필요) 등의 도구를 사용하여 단순 코드 완성이나 기술 지원을 넘어서는 도움을 제공합니다. Cline은 Model Context Protocol(MCP)를 사용하여 새로운 도구를 만들고 자신의 기능을 확장할 수도 있습니다. 자율적인 AI 스크립트는 일반적으로 샌드박스 환경에서 실행되지만, 이 확장 프로그램은 모든 파일 변경 및 터미널 명령을 승인할 수 있는 사람이 개입가능한 GUI를 제공하여, 에이전트형 AI의 잠재력을 보다 안전하고 쉽게 탐색할 수 있도록 합니다.
1. 작업을 입력하고, 목업을 기능하는 앱으로 변환하거나 스크린샷으로 버그를 수정합니다.
2. Cline은 파일 구조와 소스코드 AST의 분석, 정규식 검색 실행, 관련 파일 읽기부터 시작하여 기존 프로젝트를 파악합니다. 또한, 어떤 정보를 컨텍스트에 추가할지를 신중하게 관리하여, 대규모 복잡한 프로젝트에서도 컨텍스트 윈도우를 과부하시키지 않으면서도 효과적인 지원을 제공합니다.
3. Cline이 필요한 정보를 얻은 후 다음과 같은 작업을 할 수 있습니다:
- 파일 생성과 편집 + 린터/컴파일러 오류 모니터링을 수행하여 누락된 임포트나 구문 오류 등의 문제를 자동으로 수정합니다.
- 터미널에서 명령을 직접 실행하고 작업 중에 출력을 모니터링합니다. 이를 통해 파일 편집 후 개발 서버의 문제에 대응할 수 있습니다.
- 웹 개발 작업에서는 헤드리스 브라우저로 사이트를 실행하고, 클릭, 입력, 스크롤, 스크린샷과 콘솔 로그 캡처를 수행하여 런타임 오류나 시각적 버그를 수정합니다.
4. 작업이 완료되면 Cline은 `open -a "Google Chrome" index.html`과 같은 터미널 명령을 제공하여 버튼 클릭 한 번으로 결과를 확인할 수 있도록 합니다.
> [!TIP]
> `CMD/CTRL + Shift + P` 단축키를 사용하여 명령 팔레트를 열고 "Cline: Open In New Tab"을 입력하여 에디터의 탭으로 확장 프로그램을 엽니다. 이를 통해 파일 탐색기와 병행하여 Cline을 사용하고 워크스페이스의 변경을 더 명확하게 확인할 수 있습니다.
---
<img align="right" width="340" src="https://github.com/user-attachments/assets/3cf21e04-7ce9-4d22-a7b9-ba2c595e88a4">
### 어떤 API나 모델이든 사용 가능
Cline은 OpenRouter, Anthropic, OpenAI, Google Gemini, AWS Bedrock, Azure, GCP Vertex 등의 API 제공자를 지원합니다. 또한 OpenAI 호환 API를 설정하거나 LM Studio/Ollama를 통해 로컬 모델을 사용할 수도 있습니다. OpenRouter를 사용하는 경우, 확장 프로그램에서 최신 모델 목록을 가져와 바로 최신 모델을 사용할 수 있게 합니다.
또한, Cline은 전체 작업 루프와 개별 요청별로 토큰 사용량과 API 비용을 추적하여, 진행 중인 작업의 비용을 실시간으로 확인할 수 있도록 도와줍니다.
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
<img align="left" width="370" src="https://github.com/user-attachments/assets/81be79a8-1fdb-4028-9129-5fe055e01e76">
### 터미널에서 명령 실행
VSCode v1.93의 새로운 [셸 통합 업데이트](https://code.visualstudio.com/updates/v1_93#_terminal-shell-integration-api) 덕분에, Cline은 터미널에서 명령을 직접 실행하고 출력을 받을 수 있습니다. 이를 통해 패키지 설치나 빌드 스크립트 실행부터 애플리케이션 배포, 데이터베이스 관리, 테스트 실행까지 광범위한 작업을 수행할 수 있습니다. Cline은 개발 환경과 도구 체인에 맞추어 정확하게 작업을 실행합니다.
개발 서버와 같은 오래 실행되는 프로세스의 경우, "실행 중 계속"(Proceed While Running) 버튼을 사용하여 명령이 백그라운드에서 실행되는 동안 Cline이 작업을 계속할 수 있게 합니다. 작업이 진행되는 동안 Cline은 새로운 터미널 출력을 실시간으로 확인하여, 파일 편집 시 발생하는 컴파일 오류와 같은 문제에 즉시 대응할 수 있습니다.
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
<img align="right" width="400" src="https://github.com/user-attachments/assets/c5977833-d9b8-491e-90f9-05f9cd38c588">
### 파일 생성과 편집
Cline은 에디터 내에서 파일을 생성 및 편집하고 변경의 Diff 뷰로 표시합니다. Diff 뷰 에디터에서 Cline의 변경을 직접 편집하거나 되돌릴 수 있으며, 채팅에서 피드백을 제공하여 만족할 때까지 개선 요청할 수 있습니다. Cline은 린터/컴파일러 오류(누락된 임포트, 구문 오류 등)도 모니터링하고 발생한 문제를 자동으로 수정합니다.
Cline에 의한 모든 변경은 파일의 타임라인에 기록되어 필요할 때 변경을 추적하고 되돌릴 수 있는 간단한 방법을 제공합니다.
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
<img align="left" width="370" src="https://github.com/user-attachments/assets/bc2e85ba-dfeb-4fe6-9942-7cfc4703cbe5">
### 브라우저 사용
Claude 3.5 Sonnet의 새로운 [컴퓨터 사용](https://www.anthropic.com/news/3-5-models-and-computer-use) 기능으로 인해, Cline은 브라우저를 실행하고 요소를 클릭하고 텍스트를 입력하고 스크롤하며 각 단계에서 스크린샷과 콘솔 로그를 캡처할 수 있습니다. 이를 통해 인터랙티브한 디버깅, 엔드투엔드 테스트, 심지어 일반적인 웹 탐색까지 가능해집니다. 이로 인해 오류 로그를 수동으로 복사 & 붙여넣기 할 필요 없이 시각적 버그나 런타임 문제를 자율적으로 수정할 수 있습니다.
Cline에게 "앱을 테스트해줘"라고 요청하면, `npm run dev`와 같은 명령을 실행하고 로컬에서 실행 중인 개발 서버를 브라우저에서 실행하여 일련의 테스트를 수행하고 모든 것이 정상적으로 작동하는지 확인합니다. [데모는 여기를 참조하세요.](https://x.com/sdrzn/status/1850880547825823989)
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
<img align="right" width="350" src="https://github.com/user-attachments/assets/ac0efa14-5c1f-4c26-a42d-9d7c56f5fadd">
### "도구를 추가 해주세요."
Cline은 [Model Context Protocol](https://github.com/modelcontextprotocol)을 활용하여 커스텀 도구를 생성하고 기능을 확장할 수 있습니다. 기존의 [커뮤니티 서버](https://github.com/modelcontextprotocol/servers)를 사용할 수도 있지만, Cline은 사용자의 워크플로우에 최적화된 도구를 직접 제작하고 설치할 수도 있습니다. "~ 도구를 추가해주세요."라고 요청만 하면, Cline은 새로운 MCP 서버 생성부터 확장 프로그램 내 설치까지 모두 자동으로 처리합니다. 이러한 커스텀 도구는 Cline의 툴키트의 일부가 되어 향후 작업에서 사용할 수 있게 됩니다.
- "Jira 티켓을 가져오는 도구를 추가해주세요": 티켓 AC를 가져와 Cline에게 작업을 요청
- "AWS EC2를 관리하는 도구를 추가해주세요": 서버 메트릭을 확인하고 인스턴스를 확장 또는 축소
- "최신 PagerDuty 인시던트를 가져오는 도구를 추가해주세요": 최신 장애 정보를 가져와 Cline에게 버그 수정 요청
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
<img align="left" width="360" src="https://github.com/user-attachments/assets/7fdf41e6-281a-4b4b-ac19-020b838b6970">
### 컨텍스트 추가
**`@url`:** URL을 붙여넣으면 확장이 해당 페이지를 가져와 Markdown으로 변환합니다. 최신 문서를 Cline에게 제공할 때 유용합니다.
**`@problems`:** Cline이 수정할 워크스페이스 오류와 경고(Problems' panel)를 추가합니다.
**`@file`:** 파일의 내용을 추가하여, 파일을 읽는 데 API 요청을 허비하지 않고도 Cline이 접근할 수 있도록 합니다. (+ 파일 검색 가능)
**`@folder`:** 폴더 내 모든 파일을 한 번에 추가하여 워크플로우를 더욱 빠르게 진행할 수 있습니다.
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
<img align="right" width="350" src="https://github.com/user-attachments/assets/140c8606-d3bf-41b9-9a1f-4dbf0d4c90cb">
### 체크포인트: 비교 및 복원
Cline이 작업을 진행하는 동안 확장 프로그램은 각 단계에서 워크스페이스의 스냅샷을 저장합니다. “Compare” 버튼을 사용하여 스냅샷과 현재 워크스페이스의 차이를 확인하고, “Restore” 버튼을 사용하여 해당 시점으로 롤백할 수 있습니다.
예를 들어, 로컬 웹 서버에서 작업 중일 때 “Restore Workspace Only”을 사용하여 서로 다른 버전의 앱을 신속하게 테스트하고, “Restore Task and Workspace”을 사용하여 계속 진행할 버전을 찾을 수 있습니다. 이를 통해 진행 상황을 잃지 않고 안전하게 다양한 접근 방식을 실험할 수 있습니다.
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
## 기여
프로젝트에 기여하려면, [기여 가이드](CONTRIBUTING.md)에서 기본 사항을 익히세요. 또한, [Discord](https://discord.gg/cline)에 참여하여 `#contributors` 채널에서 다른 기여자들과 이야기할 수 있습니다. 풀타임 직업을 찾고 있다면, [채용 페이지](https://cline.bot/join-us)에서 열려있는 포지션을 확인하세요.
<details>
<summary>로컬 개발 방법</summary>
1. 리포지토리를 클론합니다 _(Requires [git-lfs](https://git-lfs.com/))_
```bash
git clone https://github.com/cline/cline.git
```
2. 프로젝트를 VSCode에서 엽니다:
```bash
code cline
```
3. 확장 프로그램과 webview-gui의 필요한 의존성을 설치합니다:
```bash
npm run install:all
```
4. `F5`를 눌러(또는 `Run`->`Start Debugging`), 확장 프로그램이 로드된 새로운 VSCode 창을 엽니다. (프로젝트 빌드에 문제가 있는 경우, [esbuild problem matchers extension](https://marketplace.visualstudio.com/items?itemName=connor4312.esbuild-problem-matchers)을 설치해야 할 수도 있습니다.)
</details>
<details>
<summary>Pull Request 생성 방법</summary>
1. PR을 만들기 전, 변경 사항을 기록하는 changeset 항목을 생성:
```bash
npm run changeset
```
이후 프롬프트에서 다음 정보를 입력하세요:
- 변경 유형 (major, minor, patch)
- `major` → 호환되지 않는 변경 (1.0.0 → 2.0.0)
- `minor` → 새로운 기능 추가 (1.0.0 → 1.1.0)
- `patch` → 버그 수정 (1.0.0 → 1.0.1)
- 변경 사항 설명 입력
2. 변경 사항과 생성된 `.changeset` 파일을 커밋 후 브랜치를 푸시하고 GitHub에서 PR을 생성하세요.
3. 브랜치를 푸시하고 GitHub에서 PR을 생성하세요. CI가 다음과 같은 작업을 수행합니다:
- 테스트 및 코드 검증 실행
- Changesetbot이 버전 변경 영향을 보여주는 코멘트를 생성
- 브랜치가 메인에 머지되면, Changesetbot이 버전 패키지 PR을 생성
- 버전 패키지 PR이 머지되면, 새로운 릴리즈가 게시됨
</details>
## 라이센스
[Apache 2.0 © 2025 Cline Bot Inc.](/LICENSE)
+2 -2
View File
@@ -28,7 +28,7 @@
Conheça o Cline: um assistente de IA que pode usar seu **CLI** e **Editor**.
Graças às [habilidades avançadas do Claude 3.7 Sonnet](https://www.anthropic.com/claude/sonnet), o Cline pode lidar com tarefas complexas de desenvolvimento de software passo a passo. Com ferramentas que permitem criar e editar arquivos, explorar grandes projetos, usar o navegador e executar comandos no terminal (com sua aprovação), ele pode ajudar você de maneiras que vão além da inclusão de código ou suporte técnico. O Cline pode é capaz inclusive de usar o Model Context Protocol (MCP) para criar novas ferramentas e expandir seus próprios recursos. Embora os scripts de IA autônomas tradicionalmente sejam executados em ambientes isolados, esta extensão oferece uma GUI com um humano no circuito para aprovar cada alteração de arquivo e comando de terminal, fornecendo uma maneira segura e acessível de explorar todo o potencial da IA.
Graças às [habilidades avançadas do Claude 3.5 Sonnet](https://www-cdn.anthropic.com/fed9cc193a14b84131812372d8d5857f8f304c52/Model_Card_Claude_3_Addendum.pdf), o Cline pode lidar com tarefas complexas de desenvolvimento de software passo a passo. Com ferramentas que permitem criar e editar arquivos, explorar grandes projetos, usar o navegador e executar comandos no terminal (com sua aprovação), ele pode ajudar você de maneiras que vão além da inclusão de código ou suporte técnico. O Cline pode é capaz inclusive de usar o Model Context Protocol (MCP) para criar novas ferramentas e expandir seus próprios recursos. Embora os scripts de IA autônomas tradicionalmente sejam executados em ambientes isolados, esta extensão oferece uma GUI com um humano no circuito para aprovar cada alteração de arquivo e comando de terminal, fornecendo uma maneira segura e acessível de explorar todo o potencial da IA.
1. Insira sua tarefa e adicione imagens para transformar mockups em aplicativos funcionais ou corrigir erros através de capturas de tela.
@@ -158,4 +158,4 @@ Para contribuir com o projeto, comece com nosso [Guia de Contribuição](CONTRIB
## Licença
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
[Apache 2.0 © 2024 Cline Bot Inc.](./LICENSE)
+2 -2
View File
@@ -28,7 +28,7 @@
认识 Cline,一个可以使用你的 **CLI****编辑器** 的 AI 助手。
感谢 [Claude 3.7 Sonnet 的代理编码能力](https://www.anthropic.com/claude/sonnet),Cline 可以一步步处理复杂的软件开发任务。通过允许他创建和编辑文件、探索大型项目、使用浏览器和执行终端命令(在你授予权限后),他可以提供超越代码完成或技术支持的帮助。Cline 甚至可以使用 Model Context Protocol (MCP) 创建新工具并扩展自己的能力。虽然自主 AI 脚本传统上在沙盒环境中运行,但此扩展提供了一个人机交互的 GUI 来批准每个文件更改和终端命令,提供了一种安全且可访问的方式来探索代理 AI 的潜力。
感谢 [Claude 3.5 Sonnet 的代理编码能力](https://www-cdn.anthropic.com/fed9cc193a14b84131812372d8d5857f8f304c52/Model_Card_Claude_3_Addendum.pdf),Cline 可以一步步处理复杂的软件开发任务。通过允许他创建和编辑文件、探索大型项目、使用浏览器和执行终端命令(在你授予权限后),他可以提供超越代码完成或技术支持的帮助。Cline 甚至可以使用 Model Context Protocol (MCP) 创建新工具并扩展自己的能力。虽然自主 AI 脚本传统上在沙盒环境中运行,但此扩展提供了一个人机交互的 GUI 来批准每个文件更改和终端命令,提供了一种安全且可访问的方式来探索代理 AI 的潜力。
1. 输入你的任务并添加图像,将模型转换为功能应用程序或通过截图修复错误。
2. Cline 首先分析你的文件结构和源代码 AST,运行正则表达式搜索,并阅读相关文件以了解现有项目。通过仔细管理添加到上下文中的信息,Cline 即使在大型复杂项目中也能提供有价值的帮助,而不会使上下文窗口过载。
@@ -158,5 +158,5 @@ Cline 所做的所有更改都会记录在你的文件时间轴中,提供了
## 许可证
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
[Apache 2.0 © 2024 Cline Bot Inc.](./LICENSE)
+2 -2
View File
@@ -28,7 +28,7 @@
認識 Cline,一個可以使用你的 **CLI****編輯器** 的 AI 助手。
感謝 [Claude 3.7 Sonnet 的代理編碼能力](https://www.anthropic.com/claude/sonnet),Cline 可以一步步處理複雜的軟件開發任務。通過允許他創建和編輯文件、探索大型項目、使用瀏覽器和執行終端命令(在你授予權限後),他可以提供超越代碼完成或技術支持的幫助。Cline 甚至可以使用 Model Context Protocol (MCP) 創建新工具並擴展自己的能力。雖然自主 AI 腳本傳統上在沙盒環境中運行,但此擴展提供了一個人機交互的 GUI 來批准每個文件更改和終端命令,提供了一種安全且可訪問的方式來探索代理 AI 的潛力。
感謝 [Claude 3.5 Sonnet 的代理編碼能力](https://www-cdn.anthropic.com/fed9cc193a14b84131812372d8d5857f8f304c52/Model_Card_Claude_3_Addendum.pdf),Cline 可以一步步處理複雜的軟件開發任務。通過允許他創建和編輯文件、探索大型項目、使用瀏覽器和執行終端命令(在你授予權限後),他可以提供超越代碼完成或技術支持的幫助。Cline 甚至可以使用 Model Context Protocol (MCP) 創建新工具並擴展自己的能力。雖然自主 AI 腳本傳統上在沙盒環境中運行,但此擴展提供了一個人機交互的 GUI 來批准每個文件更改和終端命令,提供了一種安全且可訪問的方式來探索代理 AI 的潛力。
1. 輸入你的任務並添加圖像,將模型轉換為功能應用程序或通過截圖修復錯誤。
2. Cline 首先分析你的文件結構和源代碼 AST,運行正則表達式搜索,並閱讀相關文件以了解現有項目。通過仔細管理添加到上下文中的信息,Cline 即使在大型複雜項目中也能提供有價值的幫助,而不會使上下文窗口過載。
@@ -158,4 +158,4 @@ Cline 所做的所有更改都會記錄在你的文件時間軸中,提供了
## 許可證
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
[Apache 2.0 © 2024 Cline Bot Inc.](./LICENSE)
+177 -2175
View File
File diff suppressed because it is too large Load Diff
+10 -48
View File
@@ -2,7 +2,7 @@
"name": "claude-dev",
"displayName": "Cline",
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
"version": "3.7.0",
"version": "3.4.0",
"icon": "assets/icons/icon.png",
"galleryBanner": {
"color": "#617A91",
@@ -73,7 +73,7 @@
{
"command": "cline.mcpButtonClicked",
"title": "MCP Servers",
"icon": "$(extensions)"
"icon": "$(server)"
},
{
"command": "cline.historyButtonClicked",
@@ -160,7 +160,7 @@
"cline.enableCheckpoints": {
"type": "boolean",
"default": true,
"description": "Enables extension to save checkpoints of workspace throughout the task. Uses git under the hood which may not work well with large workspaces."
"description": "Enables extension to save checkpoints of workspace throughout the task."
},
"cline.disableBrowserTool": {
"type": "boolean",
@@ -181,36 +181,6 @@
"type": "string",
"default": null,
"description": "Path to Chrome executable for browser use functionality. If not set, the extension will attempt to find or download it automatically."
},
"cline.preferredLanguage": {
"type": "string",
"enum": [
"English",
"Arabic - العربية",
"Portuguese - Português (Brasil)",
"Czech - Čeština",
"French - Français",
"German - Deutsch",
"Hindi - हिन्दी",
"Hungarian - Magyar",
"Italian - Italiano",
"Japanese - 日本語",
"Korean - 한국어",
"Polish - Polski",
"Portuguese - Português (Portugal)",
"Russian - Русский",
"Simplified Chinese - 简体中文",
"Spanish - Español",
"Traditional Chinese - 繁體中文",
"Turkish - Türkçe"
],
"default": "English",
"description": "The language that Cline should use for communication."
},
"cline.mcpMarketplace.enabled": {
"type": "boolean",
"default": true,
"description": "Controls whether the MCP Marketplace is enabled."
}
}
}
@@ -226,12 +196,12 @@
"watch-tests": "tsc -p . -w --outDir out",
"pretest": "npm run compile-tests && npm run compile && npm run lint",
"check-types": "tsc --noEmit",
"lint": "eslint src --ext ts && eslint webview-ui/src --ext ts",
"lint": "eslint src --ext ts",
"format": "prettier . --check",
"format:fix": "prettier . --write",
"test": "vscode-test",
"install:all": "npm install && cd webview-ui && npm install",
"dev:webview": "cd webview-ui && npm run dev",
"start:webview": "cd webview-ui && npm run start",
"build:webview": "cd webview-ui && npm run build",
"test:webview": "cd webview-ui && npm run test",
"publish:marketplace": "vsce publish && ovsx publish",
@@ -247,7 +217,6 @@
"@types/mocha": "^10.0.7",
"@types/node": "20.x",
"@types/should": "^11.2.0",
"@types/sinon": "^17.0.4",
"@types/vscode": "^1.84.0",
"@typescript-eslint/eslint-plugin": "^7.14.1",
"@typescript-eslint/parser": "^7.11.0",
@@ -260,15 +229,12 @@
"npm-run-all": "^4.1.5",
"prettier": "^3.3.3",
"should": "^13.2.3",
"sinon": "^19.0.2",
"typescript": "^5.4.5"
},
"dependencies": {
"@anthropic-ai/bedrock-sdk": "^0.12.4",
"@anthropic-ai/sdk": "^0.37.0",
"@anthropic-ai/vertex-sdk": "^0.6.4",
"@aws-sdk/client-bedrock-runtime": "^3.758.0",
"@google-cloud/vertexai": "^1.9.3",
"@anthropic-ai/bedrock-sdk": "^0.10.2",
"@anthropic-ai/sdk": "^0.26.0",
"@anthropic-ai/vertex-sdk": "^0.4.1",
"@google/generative-ai": "^0.18.0",
"@mistralai/mistralai": "^1.5.0",
"@modelcontextprotocol/sdk": "^1.0.1",
@@ -277,7 +243,7 @@
"@types/pdf-parse": "^1.1.4",
"@types/turndown": "^5.0.5",
"@vscode/codicons": "^0.0.36",
"axios": "^1.8.2",
"axios": "^1.7.4",
"cheerio": "^1.0.0",
"chokidar": "^4.0.1",
"clone-deep": "^4.0.1",
@@ -293,14 +259,10 @@
"isbinaryfile": "^5.0.2",
"mammoth": "^1.8.0",
"monaco-vscode-textmate-theme-converter": "^0.1.7",
"ollama": "^0.5.13",
"open-graph-scraper": "^6.9.0",
"openai": "^4.83.0",
"os-name": "^6.0.0",
"p-timeout": "^6.1.4",
"p-wait-for": "^5.0.2",
"pdf-parse": "^1.1.1",
"posthog-node": "^4.8.1",
"puppeteer-chromium-resolver": "^23.0.0",
"puppeteer-core": "^23.4.0",
"serialize-error": "^11.0.3",
@@ -309,6 +271,6 @@
"tree-sitter-wasms": "^0.1.11",
"turndown": "^7.2.0",
"web-tree-sitter": "^0.22.6",
"zod": "^3.24.2"
"zod": "^3.23.8"
}
}
+1 -14
View File
@@ -9,23 +9,18 @@ import { OllamaHandler } from "./providers/ollama"
import { LmStudioHandler } from "./providers/lmstudio"
import { GeminiHandler } from "./providers/gemini"
import { OpenAiNativeHandler } from "./providers/openai-native"
import { ApiStream, ApiStreamUsageChunk } from "./transform/stream"
import { ApiStream } from "./transform/stream"
import { DeepSeekHandler } from "./providers/deepseek"
import { RequestyHandler } from "./providers/requesty"
import { TogetherHandler } from "./providers/together"
import { QwenHandler } from "./providers/qwen"
import { MistralHandler } from "./providers/mistral"
import { VsCodeLmHandler } from "./providers/vscode-lm"
import { ClineHandler } from "./providers/cline"
import { LiteLlmHandler } from "./providers/litellm"
import { AskSageHandler } from "./providers/asksage"
import { XAIHandler } from "./providers/xai"
import { SambanovaHandler } from "./providers/sambanova"
export interface ApiHandler {
createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream
getModel(): { id: string; info: ModelInfo }
getApiStreamUsage?(): Promise<ApiStreamUsageChunk | undefined>
}
export interface SingleCompletionHandler {
@@ -65,16 +60,8 @@ export function buildApiHandler(configuration: ApiConfiguration): ApiHandler {
return new MistralHandler(options)
case "vscode-lm":
return new VsCodeLmHandler(options)
case "cline":
return new ClineHandler(options)
case "litellm":
return new LiteLlmHandler(options)
case "asksage":
return new AskSageHandler(options)
case "xai":
return new XAIHandler(options)
case "sambanova":
return new SambanovaHandler(options)
default:
return new AnthropicHandler(options)
}
+3 -36
View File
@@ -20,15 +20,10 @@ export class AnthropicHandler implements ApiHandler {
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const model = this.getModel()
let stream: AnthropicStream<Anthropic.RawMessageStreamEvent>
let stream: AnthropicStream<Anthropic.Beta.PromptCaching.Messages.RawPromptCachingBetaMessageStreamEvent>
const modelId = model.id
let budget_tokens = this.options.thinkingBudgetTokens || 0
const reasoningOn = modelId.includes("3-7") && budget_tokens !== 0 ? true : false
switch (modelId) {
// 'latest' alias does not support cache_control
case "claude-3-7-sonnet-20250219":
case "claude-3-5-sonnet-20241022":
case "claude-3-5-haiku-20241022":
case "claude-3-opus-20240229":
@@ -42,14 +37,11 @@ export class AnthropicHandler implements ApiHandler {
)
const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1
const secondLastMsgUserIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1
stream = await this.client.messages.create(
stream = await this.client.beta.promptCaching.messages.create(
{
model: modelId,
thinking: reasoningOn ? { type: "enabled", budget_tokens: budget_tokens } : undefined,
max_tokens: model.info.maxTokens || 8192,
// "Thinking isnt compatible with temperature, top_p, or top_k modifications as well as forced tool use."
// (https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking)
temperature: reasoningOn ? undefined : 0,
temperature: 0,
system: [
{
text: systemPrompt,
@@ -96,7 +88,6 @@ export class AnthropicHandler implements ApiHandler {
// https://github.com/anthropics/anthropic-sdk-typescript?tab=readme-ov-file#default-headers
// https://github.com/anthropics/anthropic-sdk-typescript/commit/c920b77fc67bd839bfeb6716ceab9d7c9bbe7393
switch (modelId) {
case "claude-3-7-sonnet-20250219":
case "claude-3-5-sonnet-20241022":
case "claude-3-5-haiku-20241022":
case "claude-3-opus-20240229":
@@ -155,20 +146,6 @@ export class AnthropicHandler implements ApiHandler {
break
case "content_block_start":
switch (chunk.content_block.type) {
case "thinking":
yield {
type: "reasoning",
reasoning: chunk.content_block.thinking || "",
}
break
case "redacted_thinking":
// Handle redacted thinking blocks - we still mark it as reasoning
// but note that the content is encrypted
yield {
type: "reasoning",
reasoning: "[Redacted thinking block]",
}
break
case "text":
// we may receive multiple text blocks, in which case just insert a line break between them
if (chunk.index > 0) {
@@ -186,22 +163,12 @@ export class AnthropicHandler implements ApiHandler {
break
case "content_block_delta":
switch (chunk.delta.type) {
case "thinking_delta":
yield {
type: "reasoning",
reasoning: chunk.delta.thinking,
}
break
case "text_delta":
yield {
type: "text",
text: chunk.delta.text,
}
break
case "signature_delta":
// We don't need to do anything with the signature in the client
// It's used when sending the thinking block back to the API
break
}
break
case "content_block_stop":
-115
View File
@@ -1,115 +0,0 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { ApiHandler } from ".."
import {
ApiHandlerOptions,
ModelInfo,
AskSageModelId,
askSageModels,
askSageDefaultModelId,
askSageDefaultURL,
} from "../../shared/api"
import { ApiStream } from "../transform/stream"
type AskSageRequest = {
system_prompt: string
message: {
user: "gpt" | "me"
message: string
}[]
model: string
dataset: "none"
}
type AskSageResponse = {
uuid: string
status: number
// Response status
response: string
// Generated response message
message: string
}
export class AskSageHandler implements ApiHandler {
private options: ApiHandlerOptions
private apiUrl: string
private apiKey: string
constructor(options: ApiHandlerOptions) {
console.log("init api url", options.asksageApiUrl, askSageDefaultURL)
this.options = options
this.apiKey = options.asksageApiKey || ""
this.apiUrl = options.asksageApiUrl || askSageDefaultURL
if (!this.apiKey) {
throw new Error("AskSage API key is required")
}
}
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
try {
const model = this.getModel()
// Transform messages into AskSageRequest format
const formattedMessages = messages.map((msg) => {
const content = Array.isArray(msg.content)
? msg.content.map((block) => ("text" in block ? block.text : "")).join("")
: msg.content
return {
user: msg.role === "assistant" ? ("gpt" as const) : ("me" as const),
message: content,
}
})
const request: AskSageRequest = {
system_prompt: systemPrompt,
message: formattedMessages,
model: model.id,
dataset: "none",
}
// Make request to AskSage API
const response = await fetch(`${this.apiUrl}/query`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-access-tokens": this.apiKey,
},
body: JSON.stringify(request),
})
if (!response.ok) {
const error = await response.text()
throw new Error(`AskSage API error: ${error}`)
}
const result = (await response.json()) as AskSageResponse
if (!result.message) {
throw new Error("No content in AskSage response")
}
// Return entire response as a single chunk since streaming is not supported
yield {
type: "text",
text: result.message,
}
} catch (error) {
if (error instanceof Error) {
throw new Error(`AskSage request failed: ${error.message}`)
}
}
}
getModel(): { id: string; info: ModelInfo } {
const modelId = this.options.apiModelId
if (modelId && modelId in askSageModels) {
const id = modelId as AskSageModelId
return { id, info: askSageModels[id] }
}
return {
id: askSageDefaultModelId,
info: askSageModels[askSageDefaultModelId],
}
}
}
+65 -385
View File
@@ -1,92 +1,88 @@
import AnthropicBedrock from "@anthropic-ai/bedrock-sdk"
import { Anthropic } from "@anthropic-ai/sdk"
import { withRetry } from "../retry"
import { ApiHandler } from "../"
import { convertToR1Format } from "../transform/r1-format"
import { ApiHandlerOptions, bedrockDefaultModelId, BedrockModelId, bedrockModels, ModelInfo } from "../../shared/api"
import { calculateApiCostOpenAI } from "../../utils/cost"
import { ApiStream } from "../transform/stream"
import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
import { BedrockRuntimeClient, InvokeModelWithResponseStreamCommand } from "@aws-sdk/client-bedrock-runtime"
import { fromIni } from "@aws-sdk/credential-providers"
// https://docs.anthropic.com/en/api/claude-on-amazon-bedrock
export class AwsBedrockHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: AnthropicBedrock | any
private initializationPromise: Promise<void>
constructor(options: ApiHandlerOptions) {
this.options = options
this.initializationPromise = this.initializeClient()
}
private async initializeClient() {
let clientConfig: any = {
awsRegion: this.options.awsRegion || "us-east-1",
}
try {
if (this.options.awsUseProfile) {
// Use profile-based credentials if enabled
// Use named profile, defaulting to 'default' if not specified
var credentials: any
if (this.options.awsProfile) {
credentials = await fromIni({
profile: this.options.awsProfile,
ignoreCache: true,
})()
} else {
credentials = await fromIni({
ignoreCache: true,
})()
}
clientConfig.awsAccessKey = credentials.accessKeyId
clientConfig.awsSecretKey = credentials.secretAccessKey
clientConfig.awsSessionToken = credentials.sessionToken
} else if (this.options.awsAccessKey && this.options.awsSecretKey) {
// Use direct credentials if provided
clientConfig.awsAccessKey = this.options.awsAccessKey
clientConfig.awsSecretKey = this.options.awsSecretKey
if (this.options.awsSessionToken) {
clientConfig.awsSessionToken = this.options.awsSessionToken
}
}
} catch (error) {
console.error("Failed to initialize Bedrock client:", error)
throw error
} finally {
this.client = new AnthropicBedrock(clientConfig)
}
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
// cross region inference requires prefixing the model id with the region
let modelId = await this.getModelId()
const model = this.getModel()
// Check if this is a Deepseek model
if (modelId.includes("deepseek")) {
yield* this.createDeepseekMessage(systemPrompt, messages, modelId, model)
return
let modelId: string
if (this.options.awsUseCrossRegionInference) {
let regionPrefix = (this.options.awsRegion || "").slice(0, 3)
switch (regionPrefix) {
case "us-":
modelId = `us.${this.getModel().id}`
break
case "eu-":
modelId = `eu.${this.getModel().id}`
break
default:
// cross region inference is not supported in this region, falling back to default model
modelId = this.getModel().id
break
}
} else {
modelId = this.getModel().id
}
let budget_tokens = this.options.thinkingBudgetTokens || 0
const reasoningOn = modelId.includes("3-7") && budget_tokens !== 0 ? true : false
// Get model info and message indices for caching
const userMsgIndices = messages.reduce((acc, msg, index) => (msg.role === "user" ? [...acc, index] : acc), [] as number[])
const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1
const secondLastMsgUserIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1
// Create anthropic client, using sessions created or renewed after this handler's
// initialization, and allowing for session renewal if necessary as well
const client = await this.getAnthropicClient()
const stream = await client.messages.create({
const stream = await this.client.messages.create({
model: modelId,
max_tokens: model.info.maxTokens || 8192,
thinking: reasoningOn ? { type: "enabled", budget_tokens: budget_tokens } : undefined,
temperature: reasoningOn ? undefined : 0,
system: [
{
text: systemPrompt,
type: "text",
...(this.options.awsBedrockUsePromptCache === true && {
cache_control: { type: "ephemeral" },
}),
},
],
messages: messages.map((message, index) => {
if (index === lastUserMsgIndex || index === secondLastMsgUserIndex) {
return {
...message,
content:
typeof message.content === "string"
? [
{
type: "text",
text: message.content,
...(this.options.awsBedrockUsePromptCache === true && {
cache_control: { type: "ephemeral" },
}),
},
]
: message.content.map((content, contentIndex) =>
contentIndex === message.content.length - 1
? {
...content,
...(this.options.awsBedrockUsePromptCache === true && {
cache_control: { type: "ephemeral" },
}),
}
: content,
),
}
}
return message
}),
max_tokens: this.getModel().info.maxTokens || 8192,
temperature: 0,
system: systemPrompt,
messages,
stream: true,
})
for await (const chunk of stream) {
switch (chunk.type) {
case "message_start":
@@ -95,8 +91,6 @@ export class AwsBedrockHandler implements ApiHandler {
type: "usage",
inputTokens: usage.input_tokens || 0,
outputTokens: usage.output_tokens || 0,
cacheWriteTokens: usage.cache_creation_input_tokens || undefined,
cacheReadTokens: usage.cache_read_input_tokens || undefined,
}
break
case "message_delta":
@@ -106,22 +100,9 @@ export class AwsBedrockHandler implements ApiHandler {
outputTokens: chunk.usage.output_tokens || 0,
}
break
case "content_block_start":
switch (chunk.content_block.type) {
case "thinking":
yield {
type: "reasoning",
reasoning: chunk.content_block.thinking || "",
}
break
case "redacted_thinking":
// Handle redacted thinking blocks - we still mark it as reasoning
// but note that the content is encrypted
yield {
type: "reasoning",
reasoning: "[Redacted thinking block]",
}
break
case "text":
if (chunk.index > 0) {
yield {
@@ -138,12 +119,6 @@ export class AwsBedrockHandler implements ApiHandler {
break
case "content_block_delta":
switch (chunk.delta.type) {
case "thinking_delta":
yield {
type: "reasoning",
reasoning: chunk.delta.thinking,
}
break
case "text_delta":
yield {
type: "text",
@@ -167,299 +142,4 @@ export class AwsBedrockHandler implements ApiHandler {
info: bedrockModels[bedrockDefaultModelId],
}
}
// Default AWS region
private static readonly DEFAULT_REGION = "us-east-1"
/**
* Gets AWS credentials using the provider chain
* Centralizes credential retrieval logic for all AWS services
*/
private async getAwsCredentials(): Promise<{
accessKeyId: string
secretAccessKey: string
sessionToken?: string
}> {
// Create AWS credentials by executing an AWS provider chain
const providerChain = fromNodeProviderChain()
return await AwsBedrockHandler.withTempEnv(
() => {
AwsBedrockHandler.setEnv("AWS_REGION", this.options.awsRegion)
AwsBedrockHandler.setEnv("AWS_ACCESS_KEY_ID", this.options.awsAccessKey)
AwsBedrockHandler.setEnv("AWS_SECRET_ACCESS_KEY", this.options.awsSecretKey)
AwsBedrockHandler.setEnv("AWS_SESSION_TOKEN", this.options.awsSessionToken)
AwsBedrockHandler.setEnv("AWS_PROFILE", this.options.awsProfile)
},
() => providerChain(),
)
}
/**
* Gets the AWS region to use, with fallback to default
*/
private getRegion(): string {
return this.options.awsRegion || AwsBedrockHandler.DEFAULT_REGION
}
/**
* Creates a BedrockRuntimeClient with the appropriate credentials
*/
private async getBedrockClient(): Promise<BedrockRuntimeClient> {
const credentials = await this.getAwsCredentials()
return new BedrockRuntimeClient({
region: this.getRegion(),
credentials: {
accessKeyId: credentials.accessKeyId,
secretAccessKey: credentials.secretAccessKey,
sessionToken: credentials.sessionToken,
},
...(this.options.awsBedrockEndpoint && { endpoint: this.options.awsBedrockEndpoint }),
})
}
/**
* Creates an AnthropicBedrock client with the appropriate credentials
*/
private async getAnthropicClient(): Promise<AnthropicBedrock> {
const credentials = await this.getAwsCredentials()
// Return an AnthropicBedrock client with the resolved/assumed credentials.
return new AnthropicBedrock({
awsAccessKey: credentials.accessKeyId,
awsSecretKey: credentials.secretAccessKey,
awsSessionToken: credentials.sessionToken,
awsRegion: this.getRegion(),
...(this.options.awsBedrockEndpoint && { baseURL: this.options.awsBedrockEndpoint }),
})
}
/**
* Gets the appropriate model ID, accounting for cross-region inference if enabled
*/
async getModelId(): Promise<string> {
if (this.options.awsUseCrossRegionInference) {
let regionPrefix = this.getRegion().slice(0, 3)
switch (regionPrefix) {
case "us-":
return `us.${this.getModel().id}`
case "eu-":
return `eu.${this.getModel().id}`
case "ap-":
return `apac.${this.getModel().id}`
default:
// cross region inference is not supported in this region, falling back to default model
return this.getModel().id
}
}
return this.getModel().id
}
private static async withTempEnv<R>(updateEnv: () => void, fn: () => Promise<R>): Promise<R> {
const previousEnv = { ...process.env }
try {
updateEnv()
return await fn()
} finally {
process.env = previousEnv
}
}
private static setEnv(key: string, value: string | undefined) {
if (key !== "" && value !== undefined) {
process.env[key] = value
}
}
/**
* Creates a message using the Deepseek R1 model through AWS Bedrock
*/
private async *createDeepseekMessage(
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
modelId: string,
model: { id: BedrockModelId; info: ModelInfo },
): ApiStream {
// Get Bedrock client with proper credentials
const client = await this.getBedrockClient()
// Format prompt for DeepSeek R1 according to documentation
const formattedPrompt = this.formatDeepseekR1Prompt(systemPrompt, messages)
// Prepare the request based on DeepSeek R1's expected format
const command = new InvokeModelWithResponseStreamCommand({
modelId: modelId,
contentType: "application/json",
accept: "application/json",
body: JSON.stringify({
prompt: formattedPrompt,
max_tokens: model.info.maxTokens || 8000,
temperature: 0,
}),
})
// Track token usage
const inputTokenEstimate = this.estimateInputTokens(systemPrompt, messages)
let outputTokens = 0
let isFirstChunk = true
let accumulatedTokens = 0
const TOKEN_REPORT_THRESHOLD = 100 // Report usage after accumulating this many tokens
// Execute the streaming request
const response = await client.send(command)
if (response.body) {
for await (const chunk of response.body) {
if (chunk.chunk?.bytes) {
try {
// Parse the response chunk
const decodedChunk = new TextDecoder().decode(chunk.chunk.bytes)
const parsedChunk = JSON.parse(decodedChunk)
// Report usage on first chunk
if (isFirstChunk) {
isFirstChunk = false
const totalCost = calculateApiCostOpenAI(model.info, inputTokenEstimate, 0, 0, 0)
yield {
type: "usage",
inputTokens: inputTokenEstimate,
outputTokens: 0,
totalCost: totalCost,
}
}
// Handle DeepSeek R1 response format
if (parsedChunk.choices && parsedChunk.choices.length > 0) {
// For non-streaming response (full response)
const text = parsedChunk.choices[0].text
if (text) {
const chunkTokens = this.estimateTokenCount(text)
outputTokens += chunkTokens
accumulatedTokens += chunkTokens
yield {
type: "text",
text: text,
}
if (accumulatedTokens >= TOKEN_REPORT_THRESHOLD) {
const totalCost = calculateApiCostOpenAI(model.info, 0, accumulatedTokens, 0, 0)
yield {
type: "usage",
inputTokens: 0,
outputTokens: accumulatedTokens,
totalCost: totalCost,
}
accumulatedTokens = 0
}
}
} else if (parsedChunk.delta?.text) {
// For streaming response (delta updates)
const text = parsedChunk.delta.text
const chunkTokens = this.estimateTokenCount(text)
outputTokens += chunkTokens
accumulatedTokens += chunkTokens
yield {
type: "text",
text: text,
}
// Report aggregated token usage only when threshold is reached
if (accumulatedTokens >= TOKEN_REPORT_THRESHOLD) {
const totalCost = calculateApiCostOpenAI(model.info, 0, accumulatedTokens, 0, 0)
yield {
type: "usage",
inputTokens: 0,
outputTokens: accumulatedTokens,
totalCost: totalCost,
}
accumulatedTokens = 0
}
}
} catch (error) {
console.error("Error parsing Deepseek response chunk:", error)
// Propagate the error by yielding a text response with error information
yield {
type: "text",
text: `[ERROR] Failed to parse Deepseek response: ${error instanceof Error ? error.message : String(error)}`,
}
}
}
}
// Report any remaining accumulated tokens at the end of the stream
if (accumulatedTokens > 0) {
const totalCost = calculateApiCostOpenAI(model.info, 0, accumulatedTokens, 0, 0)
yield {
type: "usage",
inputTokens: 0,
outputTokens: accumulatedTokens,
totalCost: totalCost,
}
}
// Add final total cost calculation that includes both input and output tokens
const finalTotalCost = calculateApiCostOpenAI(model.info, inputTokenEstimate, outputTokens, 0, 0)
yield {
type: "usage",
inputTokens: inputTokenEstimate,
outputTokens: outputTokens,
totalCost: finalTotalCost,
}
}
}
/**
* Formats prompt for DeepSeek R1 model according to documentation
* First uses convertToR1Format to merge consecutive messages with the same role,
* then converts to the string format that DeepSeek R1 expects
*/
private formatDeepseekR1Prompt(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): string {
// First use convertToR1Format to merge consecutive messages with the same role
const r1Messages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
// Then convert to the special string format expected by DeepSeek R1
let combinedContent = ""
for (const message of r1Messages) {
let content = ""
if (message.content) {
if (typeof message.content === "string") {
content = message.content
} else {
// Extract text content from message parts
content = message.content
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n")
}
}
combinedContent += message.role === "user" ? "User: " + content + "\n" : "Assistant: " + content + "\n"
}
// Format according to DeepSeek R1's expected prompt format
return `<begin▁of▁sentence><User>${combinedContent}<Assistant><think>\n`
}
/**
* Estimates token count based on text length (approximate)
* Note: This is a rough estimation, as the actual token count depends on the tokenizer
*/
private estimateInputTokens(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): number {
// For Deepseek R1, we estimate the token count of the formatted prompt
// The formatted prompt includes special tokens and consistent formatting
const formattedPrompt = this.formatDeepseekR1Prompt(systemPrompt, messages)
return Math.ceil(formattedPrompt.length / 4)
}
/**
* Estimates token count for a text string
*/
private estimateTokenCount(text: string): number {
// Approximate 4 characters per token
return Math.ceil(text.length / 4)
}
}
-106
View File
@@ -1,106 +0,0 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { ApiHandler } from "../"
import { ApiHandlerOptions, ModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo } from "../../shared/api"
import { createOpenRouterStream } from "../transform/openrouter-stream"
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
import axios from "axios"
import { OpenRouterErrorResponse } from "./types"
export class ClineHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: OpenAI
lastGenerationId?: string
constructor(options: ApiHandlerOptions) {
this.options = options
this.client = new OpenAI({
baseURL: "https://api.cline.bot/v1",
apiKey: this.options.clineApiKey || "",
})
}
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
this.lastGenerationId = undefined
const stream = await createOpenRouterStream(
this.client,
systemPrompt,
messages,
this.getModel(),
this.options.o3MiniReasoningEffort,
this.options.thinkingBudgetTokens,
)
for await (const chunk of stream) {
// openrouter returns an error object instead of the openai sdk throwing an error
if ("error" in chunk) {
const error = chunk.error as OpenRouterErrorResponse["error"]
console.error(`Cline API Error: ${error?.code} - ${error?.message}`)
// Include metadata in the error message if available
const metadataStr = error.metadata ? `\nMetadata: ${JSON.stringify(error.metadata, null, 2)}` : ""
throw new Error(`Cline API Error ${error.code}: ${error.message}${metadataStr}`)
}
if (!this.lastGenerationId && chunk.id) {
this.lastGenerationId = chunk.id
}
const delta = chunk.choices[0]?.delta
if (delta?.content) {
yield {
type: "text",
text: delta.content,
}
}
// Reasoning tokens are returned separately from the content
if ("reasoning" in delta && delta.reasoning) {
yield {
type: "reasoning",
// @ts-ignore-next-line
reasoning: delta.reasoning,
}
}
}
const apiStreamUsage = await this.getApiStreamUsage()
if (apiStreamUsage) {
yield apiStreamUsage
}
}
async getApiStreamUsage(): Promise<ApiStreamUsageChunk | undefined> {
if (this.lastGenerationId) {
try {
const response = await axios.get(`https://api.cline.bot/v1/generation?id=${this.lastGenerationId}`, {
headers: {
Authorization: `Bearer ${this.options.clineApiKey}`,
},
timeout: 15_000, // this request hangs sometimes
})
const generation = response.data
return {
type: "usage",
inputTokens: generation?.native_tokens_prompt || 0,
outputTokens: generation?.native_tokens_completion || 0,
totalCost: generation?.total_cost || 0,
}
} catch (error) {
// ignore if fails
console.error("Error fetching cline generation details:", error)
}
}
return undefined
}
getModel(): { id: string; info: ModelInfo } {
const modelId = this.options.openRouterModelId
const modelInfo = this.options.openRouterModelInfo
if (modelId && modelInfo) {
return { id: modelId, info: modelInfo }
}
return { id: openRouterDefaultModelId, info: openRouterDefaultModelInfo }
}
}
+9 -33
View File
@@ -3,7 +3,6 @@ import OpenAI from "openai"
import { withRetry } from "../retry"
import { ApiHandler } from "../"
import { ApiHandlerOptions, DeepSeekModelId, ModelInfo, deepSeekDefaultModelId, deepSeekModels } from "../../shared/api"
import { calculateApiCostOpenAI } from "../../utils/cost"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
import { convertToR1Format } from "../transform/r1-format"
@@ -20,37 +19,6 @@ export class DeepSeekHandler implements ApiHandler {
})
}
private async *yieldUsage(info: ModelInfo, usage: OpenAI.Completions.CompletionUsage | undefined): ApiStream {
// Deepseek reports total input AND cache reads/writes,
// see context caching: https://api-docs.deepseek.com/guides/kv_cache)
// where the input tokens is the sum of the cache hits/misses, just like OpenAI.
// This affects:
// 1) context management truncation algorithm, and
// 2) cost calculation
// Deepseek usage includes extra fields.
// Safely cast the prompt token details section to the appropriate structure.
interface DeepSeekUsage extends OpenAI.CompletionUsage {
prompt_cache_hit_tokens?: number
prompt_cache_miss_tokens?: number
}
const deepUsage = usage as DeepSeekUsage
const inputTokens = deepUsage?.prompt_tokens || 0
const outputTokens = deepUsage?.completion_tokens || 0
const cacheReadTokens = deepUsage?.prompt_cache_hit_tokens || 0
const cacheWriteTokens = deepUsage?.prompt_cache_miss_tokens || 0
const totalCost = calculateApiCostOpenAI(info, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens)
yield {
type: "usage",
inputTokens: inputTokens,
outputTokens: outputTokens,
cacheWriteTokens: cacheWriteTokens,
cacheReadTokens: cacheReadTokens,
totalCost: totalCost,
}
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const model = this.getModel()
@@ -93,7 +61,15 @@ export class DeepSeekHandler implements ApiHandler {
}
if (chunk.usage) {
yield* this.yieldUsage(model.info, chunk.usage)
yield {
type: "usage",
inputTokens: chunk.usage.prompt_tokens || 0, // (deepseek reports total input AND cache reads/writes, see context caching: https://api-docs.deepseek.com/guides/kv_cache) where the input tokens is the sum of the cache hits/misses, while anthropic reports them as separate tokens. This is important to know for 1) context management truncation algorithm, and 2) cost calculation (NOTE: we report both input and cache stats but for now set input price to 0 since all the cost calculation will be done using cache hits/misses)
outputTokens: chunk.usage.completion_tokens || 0,
// @ts-ignore-next-line
cacheReadTokens: chunk.usage.prompt_cache_hit_tokens || 0,
// @ts-ignore-next-line
cacheWriteTokens: chunk.usage.prompt_cache_miss_tokens || 0,
}
}
}
}
+1 -8
View File
@@ -23,18 +23,11 @@ export class LiteLlmHandler implements ApiHandler {
role: "system",
content: systemPrompt,
}
const modelId = this.options.liteLlmModelId || liteLlmDefaultModelId
const isOminiModel = modelId.includes("o1-mini") || modelId.includes("o3-mini")
let temperature: number | undefined = 0
if (isOminiModel) {
temperature = undefined // does not support temperature
}
const stream = await this.client.chat.completions.create({
model: this.options.liteLlmModelId || liteLlmDefaultModelId,
messages: [systemMessage, ...formattedMessages],
temperature,
temperature: 0,
stream: true,
stream_options: { include_usage: true },
})
+17 -12
View File
@@ -1,35 +1,40 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { Message, Ollama } from "ollama"
import OpenAI from "openai"
import { ApiHandler } from "../"
import { ApiHandlerOptions, ModelInfo, openAiModelInfoSaneDefaults } from "../../shared/api"
import { convertToOllamaMessages } from "../transform/ollama-format"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
export class OllamaHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: Ollama
private client: OpenAI
constructor(options: ApiHandlerOptions) {
this.options = options
this.client = new Ollama({ host: this.options.ollamaBaseUrl || "http://localhost:11434" })
this.client = new OpenAI({
baseURL: (this.options.ollamaBaseUrl || "http://localhost:11434") + "/v1",
apiKey: "ollama",
})
}
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const ollamaMessages: Message[] = [{ role: "system", content: systemPrompt }, ...convertToOllamaMessages(messages)]
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
...convertToOpenAiMessages(messages),
]
const stream = await this.client.chat({
const stream = await this.client.chat.completions.create({
model: this.getModel().id,
messages: ollamaMessages,
messages: openAiMessages,
temperature: 0,
stream: true,
options: {
num_ctx: Number(this.options.ollamaApiOptionsCtxNum) || 32768,
},
})
for await (const chunk of stream) {
if (typeof chunk.message.content === "string") {
const delta = chunk.choices[0]?.delta
if (delta?.content) {
yield {
type: "text",
text: chunk.message.content,
text: delta.content,
}
}
}
+21 -30
View File
@@ -10,7 +10,6 @@ import {
openAiNativeModels,
} from "../../shared/api"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { calculateApiCostOpenAI } from "../../utils/cost"
import { ApiStream } from "../transform/stream"
import { ChatCompletionReasoningEffort } from "openai/resources/chat/completions.mjs"
@@ -25,47 +24,31 @@ export class OpenAiNativeHandler implements ApiHandler {
})
}
private async *yieldUsage(info: ModelInfo, usage: OpenAI.Completions.CompletionUsage | undefined): ApiStream {
const inputTokens = usage?.prompt_tokens || 0
const outputTokens = usage?.completion_tokens || 0
const cacheReadTokens = usage?.prompt_tokens_details?.cached_tokens || 0
const cacheWriteTokens = 0
const totalCost = calculateApiCostOpenAI(info, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens)
yield {
type: "usage",
inputTokens: inputTokens,
outputTokens: outputTokens,
cacheWriteTokens: cacheWriteTokens,
cacheReadTokens: cacheReadTokens,
totalCost: totalCost,
}
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const model = this.getModel()
switch (model.id) {
switch (this.getModel().id) {
case "o1":
case "o1-preview":
case "o1-mini": {
// o1 doesnt support streaming, non-1 temp, or system prompt
const response = await this.client.chat.completions.create({
model: model.id,
model: this.getModel().id,
messages: [{ role: "user", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
})
yield {
type: "text",
text: response.choices[0]?.message.content || "",
}
yield* this.yieldUsage(model.info, response.usage)
yield {
type: "usage",
inputTokens: response.usage?.prompt_tokens || 0,
outputTokens: response.usage?.completion_tokens || 0,
}
break
}
case "o3-mini": {
const stream = await this.client.chat.completions.create({
model: model.id,
model: this.getModel().id,
messages: [{ role: "developer", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
stream: true,
stream_options: { include_usage: true },
@@ -80,15 +63,18 @@ export class OpenAiNativeHandler implements ApiHandler {
}
}
if (chunk.usage) {
// Only last chunk contains usage
yield* this.yieldUsage(model.info, chunk.usage)
yield {
type: "usage",
inputTokens: chunk.usage.prompt_tokens || 0,
outputTokens: chunk.usage.completion_tokens || 0,
}
}
}
break
}
default: {
const stream = await this.client.chat.completions.create({
model: model.id,
model: this.getModel().id,
// max_completion_tokens: this.getModel().info.maxTokens,
temperature: 0,
messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
@@ -104,9 +90,14 @@ export class OpenAiNativeHandler implements ApiHandler {
text: delta.content,
}
}
// contains a null value except for the last chunk which contains the token usage statistics for the entire request
if (chunk.usage) {
// Only last chunk contains usage
yield* this.yieldUsage(model.info, chunk.usage)
yield {
type: "usage",
inputTokens: chunk.usage.prompt_tokens || 0,
outputTokens: chunk.usage.completion_tokens || 0,
}
}
}
}
+1 -12
View File
@@ -6,7 +6,6 @@ import { ApiHandler } from "../index"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
import { convertToR1Format } from "../transform/r1-format"
import { ChatCompletionReasoningEffort } from "openai/resources/chat/completions.mjs"
export class OpenAiHandler implements ApiHandler {
private options: ApiHandlerOptions
@@ -33,30 +32,20 @@ export class OpenAiHandler implements ApiHandler {
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const modelId = this.options.openAiModelId ?? ""
const isDeepseekReasoner = modelId.includes("deepseek-reasoner")
const isO3Mini = modelId.includes("o3-mini")
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
...convertToOpenAiMessages(messages),
]
let temperature: number | undefined = this.options.openAiModelInfo?.temperature ?? openAiModelInfoSaneDefaults.temperature
let reasoningEffort: ChatCompletionReasoningEffort | undefined = undefined
if (isDeepseekReasoner) {
openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
}
if (isO3Mini) {
openAiMessages = [{ role: "developer", content: systemPrompt }, ...convertToOpenAiMessages(messages)]
temperature = undefined // does not support temperature
reasoningEffort = (this.options.o3MiniReasoningEffort as ChatCompletionReasoningEffort) || "medium"
}
const stream = await this.client.chat.completions.create({
model: modelId,
messages: openAiMessages,
temperature,
reasoning_effort: reasoningEffort,
temperature: 0,
stream: true,
stream_options: { include_usage: true },
})
+144 -32
View File
@@ -2,17 +2,16 @@ import { Anthropic } from "@anthropic-ai/sdk"
import axios from "axios"
import delay from "delay"
import OpenAI from "openai"
import { withRetry } from "../retry"
import { ApiHandler } from "../"
import { ApiHandlerOptions, ModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo } from "../../shared/api"
import { withRetry } from "../retry"
import { createOpenRouterStream } from "../transform/openrouter-stream"
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
import { OpenRouterErrorResponse } from "./types"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
import { convertToR1Format } from "../transform/r1-format"
export class OpenRouterHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: OpenAI
lastGenerationId?: string
constructor(options: ApiHandlerOptions) {
this.options = options
@@ -28,29 +27,121 @@ export class OpenRouterHandler implements ApiHandler {
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
this.lastGenerationId = undefined
const model = this.getModel()
const stream = await createOpenRouterStream(
this.client,
systemPrompt,
messages,
this.getModel(),
this.options.o3MiniReasoningEffort,
this.options.thinkingBudgetTokens,
)
// Convert Anthropic messages to OpenAI format
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
...convertToOpenAiMessages(messages),
]
// prompt caching: https://openrouter.ai/docs/prompt-caching
// this is specifically for claude models (some models may 'support prompt caching' automatically without this)
switch (model.id) {
case "anthropic/claude-3.5-sonnet":
case "anthropic/claude-3.5-sonnet:beta":
case "anthropic/claude-3.5-sonnet-20240620":
case "anthropic/claude-3.5-sonnet-20240620:beta":
case "anthropic/claude-3-5-haiku":
case "anthropic/claude-3-5-haiku:beta":
case "anthropic/claude-3-5-haiku-20241022":
case "anthropic/claude-3-5-haiku-20241022:beta":
case "anthropic/claude-3-haiku":
case "anthropic/claude-3-haiku:beta":
case "anthropic/claude-3-opus":
case "anthropic/claude-3-opus:beta":
openAiMessages[0] = {
role: "system",
content: [
{
type: "text",
text: systemPrompt,
// @ts-ignore-next-line
cache_control: { type: "ephemeral" },
},
],
}
// Add cache_control to the last two user messages
// (note: this works because we only ever add one user message at a time, but if we added multiple we'd need to mark the user message before the last assistant message)
const lastTwoUserMessages = openAiMessages.filter((msg) => msg.role === "user").slice(-2)
lastTwoUserMessages.forEach((msg) => {
if (typeof msg.content === "string") {
msg.content = [{ type: "text", text: msg.content }]
}
if (Array.isArray(msg.content)) {
// NOTE: this is fine since env details will always be added at the end. but if it weren't there, and the user added a image_url type message, it would pop a text part before it and then move it after to the end.
let lastTextPart = msg.content.filter((part) => part.type === "text").pop()
if (!lastTextPart) {
lastTextPart = { type: "text", text: "..." }
msg.content.push(lastTextPart)
}
// @ts-ignore-next-line
lastTextPart["cache_control"] = { type: "ephemeral" }
}
})
break
default:
break
}
// Not sure how openrouter defaults max tokens when no value is provided, but the anthropic api requires this value and since they offer both 4096 and 8192 variants, we should ensure 8192.
// (models usually default to max tokens allowed)
let maxTokens: number | undefined
switch (model.id) {
case "anthropic/claude-3.5-sonnet":
case "anthropic/claude-3.5-sonnet:beta":
case "anthropic/claude-3.5-sonnet-20240620":
case "anthropic/claude-3.5-sonnet-20240620:beta":
case "anthropic/claude-3-5-haiku":
case "anthropic/claude-3-5-haiku:beta":
case "anthropic/claude-3-5-haiku-20241022":
case "anthropic/claude-3-5-haiku-20241022:beta":
maxTokens = 8_192
break
}
let temperature = 0
let topP: number | undefined = undefined
if (this.getModel().id.startsWith("deepseek/deepseek-r1") || this.getModel().id === "perplexity/sonar-reasoning") {
// Recommended values from DeepSeek
temperature = 0.7
topP = 0.95
openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
}
// Removes messages in the middle when close to context window limit. Should not be applied to models that support prompt caching since it would continuously break the cache.
let shouldApplyMiddleOutTransform = !model.info.supportsPromptCache
// except for deepseek (which we set supportsPromptCache to true for), where because the context window is so small our truncation algo might miss and we should use openrouter's middle-out transform as a fallback to ensure we don't exceed the context window (FIXME: once we have a more robust token estimator we should not rely on this)
if (model.id === "deepseek/deepseek-chat") {
shouldApplyMiddleOutTransform = true
}
// @ts-ignore-next-line
const stream = await this.client.chat.completions.create({
model: model.id,
max_tokens: maxTokens,
temperature: temperature,
top_p: topP,
messages: openAiMessages,
stream: true,
transforms: shouldApplyMiddleOutTransform ? ["middle-out"] : undefined,
include_reasoning: true,
...(model.id === "openai/o3-mini" ? { reasoning_effort: this.options.o3MiniReasoningEffort || "medium" } : {}),
})
let genId: string | undefined
for await (const chunk of stream) {
// openrouter returns an error object instead of the openai sdk throwing an error
if ("error" in chunk) {
const error = chunk.error as OpenRouterErrorResponse["error"]
const error = chunk.error as { message?: string; code?: number }
console.error(`OpenRouter API Error: ${error?.code} - ${error?.message}`)
// Include metadata in the error message if available
const metadataStr = error.metadata ? `\nMetadata: ${JSON.stringify(error.metadata, null, 2)}` : ""
throw new Error(`OpenRouter API Error ${error.code}: ${error.message}${metadataStr}`)
throw new Error(`OpenRouter API Error ${error?.code}: ${error?.message}`)
}
if (!this.lastGenerationId && chunk.id) {
this.lastGenerationId = chunk.id
if (!genId && chunk.id) {
genId = chunk.id
}
const delta = chunk.choices[0]?.delta
@@ -63,28 +154,50 @@ export class OpenRouterHandler implements ApiHandler {
// Reasoning tokens are returned separately from the content
if ("reasoning" in delta && delta.reasoning) {
// console.log("reasoning", delta.reasoning)
yield {
type: "reasoning",
// @ts-ignore-next-line
reasoning: delta.reasoning,
}
// if (didStreamThinkTagInReasoning) {
// yield {
// type: "text",
// // @ts-ignore-next-line
// text: delta.reasoning,
// }
// } else {
// yield {
// type: "reasoning",
// // @ts-ignore-next-line
// text: delta.reasoning,
// }
// // @ts-ignore-next-line
// reasoningResponse += delta.reasoning
// if (reasoningResponse.includes("</think>")) {
// didStreamThinkTagInReasoning = true
// console.log("did hit think tag", reasoningResponse)
// }
// }
}
// if (chunk.usage) {
// yield {
// type: "usage",
// inputTokens: chunk.usage.prompt_tokens || 0,
// outputTokens: chunk.usage.completion_tokens || 0,
// }
// }
}
const apiStreamUsage = await this.getApiStreamUsage()
if (apiStreamUsage) {
yield apiStreamUsage
}
}
async getApiStreamUsage(): Promise<ApiStreamUsageChunk | undefined> {
if (this.lastGenerationId) {
if (genId) {
await delay(500) // FIXME: necessary delay to ensure generation endpoint is ready
try {
const generationIterator = this.fetchGenerationDetails(this.lastGenerationId)
const generationIterator = this.fetchGenerationDetails(genId)
const generation = (await generationIterator.next()).value
// console.log("OpenRouter generation details:", generation)
return {
yield {
type: "usage",
// cacheWriteTokens: 0,
// cacheReadTokens: 0,
@@ -98,7 +211,6 @@ export class OpenRouterHandler implements ApiHandler {
console.error("Error fetching OpenRouter generation details:", error)
}
}
return undefined
}
@withRetry({ maxRetries: 4, baseDelay: 250, maxDelay: 1000, retryAllErrors: true })
@@ -109,7 +221,7 @@ export class OpenRouterHandler implements ApiHandler {
headers: {
Authorization: `Bearer ${this.options.openRouterApiKey}`,
},
timeout: 15_000, // this request hangs sometimes
timeout: 5_000, // this request hangs sometimes
})
yield response.data?.data
} catch (error) {
+9 -24
View File
@@ -1,16 +1,7 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { ApiHandler } from "../"
import {
ApiHandlerOptions,
ModelInfo,
mainlandQwenModels,
internationalQwenModels,
mainlandQwenDefaultModelId,
internationalQwenDefaultModelId,
MainlandQwenModelId,
InternationalQwenModelId,
} from "../../shared/api"
import { ApiHandlerOptions, QwenModelId, ModelInfo, qwenDefaultModelId, qwenModels } from "../../shared/api"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
import { convertToR1Format } from "../transform/r1-format"
@@ -30,21 +21,15 @@ export class QwenHandler implements ApiHandler {
})
}
getModel(): { id: MainlandQwenModelId | InternationalQwenModelId; info: ModelInfo } {
getModel(): { id: QwenModelId; info: ModelInfo } {
const modelId = this.options.apiModelId
// Branch based on API line to let poor typescript know what to do
if (this.options.qwenApiLine === "china") {
return {
id: (modelId as MainlandQwenModelId) ?? mainlandQwenDefaultModelId,
info: mainlandQwenModels[modelId as MainlandQwenModelId] ?? mainlandQwenModels[mainlandQwenDefaultModelId],
}
} else {
return {
id: (modelId as InternationalQwenModelId) ?? internationalQwenDefaultModelId,
info:
internationalQwenModels[modelId as InternationalQwenModelId] ??
internationalQwenModels[internationalQwenDefaultModelId],
}
if (modelId && modelId in qwenModels) {
const id = modelId as QwenModelId
return { id, info: qwenModels[id] }
}
return {
id: qwenDefaultModelId,
info: qwenModels[qwenDefaultModelId],
}
}
+6 -12
View File
@@ -1,8 +1,8 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { withRetry } from "../retry"
import { ApiHandlerOptions, ModelInfo, openAiModelInfoSaneDefaults } from "../../shared/api"
import { ApiHandler } from "../index"
import { withRetry } from "../retry"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
@@ -69,19 +69,13 @@ export class RequestyHandler implements ApiHandler {
if (chunk.usage) {
const usage = chunk.usage as RequestyUsage
const inputTokens = usage.prompt_tokens || 0
const outputTokens = usage.completion_tokens || 0
const cacheWriteTokens = usage.prompt_tokens_details?.caching_tokens || undefined
const cacheReadTokens = usage.prompt_tokens_details?.cached_tokens || undefined
const totalCost = 0 // TODO: Replace with calculateApiCostOpenAI(model.info, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens)
yield {
type: "usage",
inputTokens: inputTokens,
outputTokens: outputTokens,
cacheWriteTokens: cacheWriteTokens,
cacheReadTokens: cacheReadTokens,
totalCost: totalCost,
inputTokens: usage.prompt_tokens || 0,
outputTokens: usage.completion_tokens || 0,
cacheWriteTokens: usage.prompt_tokens_details?.caching_tokens || undefined,
cacheReadTokens: usage.prompt_tokens_details?.cached_tokens || undefined,
totalCost: usage.total_cost || undefined,
}
}
}
-75
View File
@@ -1,75 +0,0 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { withRetry } from "../retry"
import { ApiHandlerOptions, ModelInfo, SambanovaModelId, sambanovaDefaultModelId, sambanovaModels } from "../../shared/api"
import { ApiHandler } from "../index"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
import { convertToR1Format } from "../transform/r1-format"
export class SambanovaHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: OpenAI
constructor(options: ApiHandlerOptions) {
this.options = options
this.client = new OpenAI({
baseURL: "https://api.sambanova.ai/v1",
apiKey: this.options.sambanovaApiKey,
})
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const model = this.getModel()
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
...convertToOpenAiMessages(messages),
]
const modelId = model.id.toLowerCase()
if (modelId.includes("deepseek") || modelId.includes("qwen") || modelId.includes("qwq")) {
openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
}
const stream = await this.client.chat.completions.create({
model: this.getModel().id,
messages: openAiMessages,
temperature: 0,
stream: true,
stream_options: { include_usage: true },
})
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta
if (delta?.content) {
yield {
type: "text",
text: delta.content,
}
}
if (chunk.usage) {
yield {
type: "usage",
inputTokens: chunk.usage.prompt_tokens || 0,
outputTokens: chunk.usage.completion_tokens || 0,
}
}
}
}
getModel(): { id: string; info: ModelInfo } {
const modelId = this.options.apiModelId
if (modelId && modelId in sambanovaModels) {
const id = modelId as SambanovaModelId
return { id, info: sambanovaModels[id] }
}
return {
id: sambanovaDefaultModelId,
info: sambanovaModels[sambanovaDefaultModelId],
}
}
}
-22
View File
@@ -1,22 +0,0 @@
// For the following openrouter error type sources, see the docs here:
// https://openrouter.ai/docs/api-reference/errors
export type OpenRouterErrorResponse = {
error: {
message: string
code: number
metadata?: OpenRouterProviderErrorMetadata | OpenRouterModerationErrorMetadata | Record<string, unknown>
}
}
export type OpenRouterProviderErrorMetadata = {
provider_name: string // The name of the provider that encountered the error
raw: unknown // The raw error from the provider
}
export type OpenRouterModerationErrorMetadata = {
reasons: string[] // Why your input was flagged
flagged_input: string // The text segment that was flagged, limited to 100 characters. If the flagged input is longer than 100 characters, it will be truncated in the middle and replaced with ...
provider_name: string // The name of the provider that requested moderation
model_slug: string
}
+43 -235
View File
@@ -4,267 +4,75 @@ import { withRetry } from "../retry"
import { ApiHandler } from "../"
import { ApiHandlerOptions, ModelInfo, vertexDefaultModelId, VertexModelId, vertexModels } from "../../shared/api"
import { ApiStream } from "../transform/stream"
import { VertexAI } from "@google-cloud/vertexai"
// https://docs.anthropic.com/en/api/claude-on-vertex-ai
export class VertexHandler implements ApiHandler {
private options: ApiHandlerOptions
private clientAnthropic: AnthropicVertex
private clientVertex: VertexAI
private client: AnthropicVertex
constructor(options: ApiHandlerOptions) {
this.options = options
this.clientAnthropic = new AnthropicVertex({
this.client = new AnthropicVertex({
projectId: this.options.vertexProjectId,
// https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-claude#regions
region: this.options.vertexRegion,
})
this.clientVertex = new VertexAI({
project: this.options.vertexProjectId,
location: this.options.vertexRegion,
})
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const model = this.getModel()
const modelId = model.id
if (modelId.includes("claude")) {
let budget_tokens = this.options.thinkingBudgetTokens || 0
const reasoningOn = modelId.includes("3-7") && budget_tokens !== 0 ? true : false
let stream
switch (modelId) {
case "claude-3-7-sonnet@20250219":
case "claude-3-5-sonnet-v2@20241022":
case "claude-3-5-sonnet@20240620":
case "claude-3-5-haiku@20241022":
case "claude-3-opus@20240229":
case "claude-3-haiku@20240307": {
// Find indices of user messages for cache control
const userMsgIndices = messages.reduce(
(acc, msg, index) => (msg.role === "user" ? [...acc, index] : acc),
[] as number[],
)
const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1
const secondLastMsgUserIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1
stream = await this.clientAnthropic.beta.messages.create(
{
model: modelId,
max_tokens: model.info.maxTokens || 8192,
thinking: reasoningOn ? { type: "enabled", budget_tokens: budget_tokens } : undefined,
temperature: reasoningOn ? undefined : 0,
system: [
{
text: systemPrompt,
type: "text",
cache_control: { type: "ephemeral" },
},
],
messages: messages.map((message, index) => {
if (index === lastUserMsgIndex || index === secondLastMsgUserIndex) {
return {
...message,
content:
typeof message.content === "string"
? [
{
type: "text",
text: message.content,
cache_control: {
type: "ephemeral",
},
},
]
: message.content.map((content, contentIndex) =>
contentIndex === message.content.length - 1
? {
...content,
cache_control: {
type: "ephemeral",
},
}
: content,
),
}
}
return {
...message,
content:
typeof message.content === "string"
? [
{
type: "text",
text: message.content,
},
]
: message.content,
}
}),
stream: true,
},
{
headers: {},
},
)
const stream = await this.client.messages.create({
model: this.getModel().id,
max_tokens: this.getModel().info.maxTokens || 8192,
temperature: 0,
system: systemPrompt,
messages,
stream: true,
})
for await (const chunk of stream) {
switch (chunk.type) {
case "message_start":
const usage = chunk.message.usage
yield {
type: "usage",
inputTokens: usage.input_tokens || 0,
outputTokens: usage.output_tokens || 0,
}
break
}
default: {
stream = await this.clientAnthropic.beta.messages.create({
model: modelId,
max_tokens: model.info.maxTokens || 8192,
temperature: 0,
system: [
{
text: systemPrompt,
type: "text",
},
],
messages: messages.map((message) => ({
...message,
content:
typeof message.content === "string"
? [
{
type: "text",
text: message.content,
},
]
: message.content,
})),
stream: true,
})
case "message_delta":
yield {
type: "usage",
inputTokens: 0,
outputTokens: chunk.usage.output_tokens || 0,
}
break
}
}
for await (const chunk of stream) {
switch (chunk.type) {
case "message_start":
const usage = chunk.message.usage
yield {
type: "usage",
inputTokens: usage.input_tokens || 0,
outputTokens: usage.output_tokens || 0,
cacheWriteTokens: usage.cache_creation_input_tokens || undefined,
cacheReadTokens: usage.cache_read_input_tokens || undefined,
}
break
case "message_delta":
yield {
type: "usage",
inputTokens: 0,
outputTokens: chunk.usage.output_tokens || 0,
}
break
case "message_stop":
break
case "content_block_start":
switch (chunk.content_block.type) {
case "thinking":
yield {
type: "reasoning",
reasoning: chunk.content_block.thinking || "",
}
break
case "redacted_thinking":
// Handle redacted thinking blocks - we still mark it as reasoning
// but note that the content is encrypted
yield {
type: "reasoning",
reasoning: "[Redacted thinking block]",
}
break
case "text":
if (chunk.index > 0) {
yield {
type: "text",
text: "\n",
}
}
case "content_block_start":
switch (chunk.content_block.type) {
case "text":
if (chunk.index > 0) {
yield {
type: "text",
text: chunk.content_block.text,
text: "\n",
}
break
}
break
case "content_block_delta":
switch (chunk.delta.type) {
case "thinking_delta":
yield {
type: "reasoning",
reasoning: chunk.delta.thinking,
}
break
case "text_delta":
yield {
type: "text",
text: chunk.delta.text,
}
break
}
break
case "content_block_stop":
break
}
}
} else {
// gemini
const generativeModel = this.clientVertex.getGenerativeModel({
model: this.getModel().id,
systemInstruction: {
role: "system",
parts: [{ text: systemPrompt }],
},
})
const request = {
contents: [
{
role: "user",
parts: messages.map((m) => {
if (typeof m.content === "string") {
return { text: m.content }
} else if (Array.isArray(m.content)) {
return {
text: m.content
.map((block) => {
if (typeof block === "string") {
return block
} else if (block.type === "text") {
return block.text
} else {
console.log("Unsupported block type", block)
return ""
}
})
.join(" "),
}
} else {
return { text: "" }
}
}),
},
],
}
const streamingResult = await generativeModel.generateContentStream(request)
for await (const chunk of streamingResult.stream) {
// If usage data is available, yield it similarly:
// yield { type: "usage", inputTokens: 0, outputTokens: 0 }
// Otherwise, just yield text:
const candidates = chunk.candidates || []
for (const candidate of candidates) {
for (const part of candidate.content?.parts || []) {
if (part.text) {
yield {
type: "text",
text: part.text,
text: chunk.content_block.text,
}
}
break
}
}
break
case "content_block_delta":
switch (chunk.delta.type) {
case "text_delta":
yield {
type: "text",
text: chunk.delta.text,
}
break
}
break
}
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { Anthropic } from "@anthropic-ai/sdk"
import * as vscode from "vscode"
import { ApiHandler, SingleCompletionHandler } from "../"
import { calculateApiCostAnthropic } from "../../utils/cost"
import { calculateApiCost } from "../../utils/cost"
import { ApiStream } from "../transform/stream"
import { convertToVsCodeLmMessages } from "../transform/vscode-lm-format"
import { SELECTOR_SEPARATOR, stringifyVsCodeLmModelSelector } from "../../shared/vsCodeSelectorUtils"
@@ -525,7 +525,7 @@ export class VsCodeLmHandler implements ApiHandler, SingleCompletionHandler {
type: "usage",
inputTokens: totalInputTokens,
outputTokens: totalOutputTokens,
totalCost: calculateApiCostAnthropic(this.getModel().info, totalInputTokens, totalOutputTokens),
totalCost: calculateApiCost(this.getModel().info, totalInputTokens, totalOutputTokens),
}
} catch (error: unknown) {
this.ensureCleanState()
-64
View File
@@ -1,64 +0,0 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { ApiHandler } from "../"
import { ApiHandlerOptions, XAIModelId, ModelInfo, xaiDefaultModelId, xaiModels } from "../../shared/api"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
export class XAIHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: OpenAI
constructor(options: ApiHandlerOptions) {
this.options = options
this.client = new OpenAI({
baseURL: "https://api.x.ai/v1",
apiKey: this.options.xaiApiKey,
})
}
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const stream = await this.client.chat.completions.create({
model: this.getModel().id,
max_completion_tokens: this.getModel().info.maxTokens,
temperature: 0,
messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
stream: true,
stream_options: { include_usage: true },
})
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta
if (delta?.content) {
yield {
type: "text",
text: delta.content,
}
}
if (chunk.usage) {
yield {
type: "usage",
inputTokens: 0,
outputTokens: chunk.usage.completion_tokens || 0,
// @ts-ignore-next-line
cacheReadTokens: chunk.usage.prompt_cache_hit_tokens || 0,
// @ts-ignore-next-line
cacheWriteTokens: chunk.usage.prompt_cache_miss_tokens || 0,
}
}
}
}
getModel(): { id: XAIModelId; info: ModelInfo } {
const modelId = this.options.apiModelId
if (modelId && modelId in xaiModels) {
const id = modelId as XAIModelId
return { id, info: xaiModels[id] }
}
return {
id: xaiDefaultModelId,
info: xaiModels[xaiDefaultModelId],
}
}
}
+107 -5
View File
@@ -1,7 +1,26 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { Content, EnhancedGenerateContentResponse, InlineDataPart, Part, TextPart } from "@google/generative-ai"
import {
Content,
EnhancedGenerateContentResponse,
FunctionCallPart,
FunctionDeclaration,
FunctionResponsePart,
InlineDataPart,
Part,
SchemaType,
TextPart,
} from "@google/generative-ai"
export function convertAnthropicContentToGemini(content: string | Anthropic.ContentBlockParam[]): Part[] {
export function convertAnthropicContentToGemini(
content:
| string
| Array<
| Anthropic.Messages.TextBlockParam
| Anthropic.Messages.ImageBlockParam
| Anthropic.Messages.ToolUseBlockParam
| Anthropic.Messages.ToolResultBlockParam
>,
): Part[] {
if (typeof content === "string") {
return [{ text: content } as TextPart]
}
@@ -19,6 +38,55 @@ export function convertAnthropicContentToGemini(content: string | Anthropic.Cont
mimeType: block.source.media_type,
},
} as InlineDataPart
case "tool_use":
return {
functionCall: {
name: block.name,
args: block.input,
},
} as FunctionCallPart
case "tool_result":
const name = block.tool_use_id.split("-")[0]
if (!block.content) {
return []
}
if (typeof block.content === "string") {
return {
functionResponse: {
name,
response: {
name,
content: block.content,
},
},
} as FunctionResponsePart
} else {
// The only case when tool_result could be array is when the tool failed and we're providing ie user feedback potentially with images
const textParts = block.content.filter((part) => part.type === "text")
const imageParts = block.content.filter((part) => part.type === "image")
const text = textParts.length > 0 ? textParts.map((part) => part.text).join("\n\n") : ""
const imageText = imageParts.length > 0 ? "\n\n(See next part for image)" : ""
return [
{
functionResponse: {
name,
response: {
name,
content: text + imageText,
},
},
} as FunctionResponsePart,
...imageParts.map(
(part) =>
({
inlineData: {
data: part.source.data,
mimeType: part.source.media_type,
},
}) as InlineDataPart,
),
]
}
default:
throw new Error(`Unsupported content block type: ${(block as any).type}`)
}
@@ -32,6 +100,26 @@ export function convertAnthropicMessageToGemini(message: Anthropic.Messages.Mess
}
}
export function convertAnthropicToolToGemini(tool: Anthropic.Messages.Tool): FunctionDeclaration {
return {
name: tool.name,
description: tool.description || "",
parameters: {
type: SchemaType.OBJECT,
properties: Object.fromEntries(
Object.entries(tool.input_schema.properties || {}).map(([key, value]) => [
key,
{
type: (value as any).type.toUpperCase(),
description: (value as any).description || "",
},
]),
),
required: (tool.input_schema.required as string[]) || [],
},
}
}
/*
It looks like gemini likes to double escape certain characters when writing file contents: https://discuss.ai.google.dev/t/function-call-string-property-is-double-escaped/37867
*/
@@ -45,7 +133,23 @@ export function convertGeminiResponseToAnthropic(response: EnhancedGenerateConte
// Add the main text response
const text = response.text()
if (text) {
content.push({ type: "text", text, citations: null })
content.push({ type: "text", text })
}
// Add function calls as tool_use blocks
const functionCalls = response.functionCalls()
if (functionCalls) {
functionCalls.forEach((call, index) => {
if ("content" in call.args && typeof call.args.content === "string") {
call.args.content = unescapeGeminiContent(call.args.content)
}
content.push({
type: "tool_use",
id: `${call.name}-${index}-${Date.now()}`,
name: call.name,
input: call.args,
})
})
}
// Determine stop reason
@@ -79,8 +183,6 @@ export function convertGeminiResponseToAnthropic(response: EnhancedGenerateConte
usage: {
input_tokens: response.usageMetadata?.promptTokenCount ?? 0,
output_tokens: response.usageMetadata?.candidatesTokenCount ?? 0,
cache_creation_input_tokens: null,
cache_read_input_tokens: null,
},
}
}
+45 -14
View File
@@ -1,4 +1,5 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { Mistral } from "@mistralai/mistralai"
import { AssistantMessage } from "@mistralai/mistralai/models/components/assistantmessage"
import { SystemMessage } from "@mistralai/mistralai/models/components/systemmessage"
import { ToolMessage } from "@mistralai/mistralai/models/components/toolmessage"
@@ -20,15 +21,25 @@ export function convertToMistralMessages(anthropicMessages: Anthropic.Messages.M
})
} else {
if (anthropicMessage.role === "user") {
// Filter to only include text and image blocks
const textAndImageBlocks = anthropicMessage.content.filter(
(part) => part.type === "text" || part.type === "image",
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]
toolMessages: Anthropic.ToolResultBlockParam[]
}>(
(acc, part) => {
if (part.type === "tool_result") {
acc.toolMessages.push(part)
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part)
} // user cannot send tool_use messages
return acc
},
{ nonToolMessages: [], toolMessages: [] },
)
if (textAndImageBlocks.length > 0) {
if (nonToolMessages.length > 0) {
mistralMessages.push({
role: "user",
content: textAndImageBlocks.map((part) => {
content: nonToolMessages.map((part) => {
if (part.type === "image") {
return {
type: "image_url",
@@ -42,17 +53,37 @@ export function convertToMistralMessages(anthropicMessages: Anthropic.Messages.M
})
}
} else if (anthropicMessage.role === "assistant") {
// Only process text blocks - assistant cannot send images or other content types in Mistral's API format
const textBlocks = anthropicMessage.content.filter((part) => part.type === "text")
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]
toolMessages: Anthropic.ToolUseBlockParam[]
}>(
(acc, part) => {
if (part.type === "tool_use") {
acc.toolMessages.push(part)
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part)
} // assistant cannot send tool_result messages
return acc
},
{ nonToolMessages: [], toolMessages: [] },
)
if (textBlocks.length > 0) {
const content = textBlocks.map((part) => part.text).join("\n")
mistralMessages.push({
role: "assistant",
content,
})
let content: string | undefined
if (nonToolMessages.length > 0) {
content = nonToolMessages
.map((part) => {
if (part.type === "image") {
return "" // impossible as the assistant cannot send images
}
return part.text
})
.join("\n")
}
mistralMessages.push({
role: "assistant",
content,
})
}
}
}
-3
View File
@@ -376,7 +376,6 @@ export function convertO1ResponseToAnthropicMessage(
{
type: "text",
text: normalText,
citations: null,
},
],
model: completion.model,
@@ -397,8 +396,6 @@ export function convertO1ResponseToAnthropicMessage(
usage: {
input_tokens: completion.usage?.prompt_tokens || 0,
output_tokens: completion.usage?.completion_tokens || 0,
cache_creation_input_tokens: null,
cache_read_input_tokens: null,
},
}
-109
View File
@@ -1,109 +0,0 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { Message } from "ollama"
export function convertToOllamaMessages(anthropicMessages: Anthropic.Messages.MessageParam[]): Message[] {
const ollamaMessages: Message[] = []
for (const anthropicMessage of anthropicMessages) {
if (typeof anthropicMessage.content === "string") {
ollamaMessages.push({
role: anthropicMessage.role,
content: anthropicMessage.content,
})
} else {
if (anthropicMessage.role === "user") {
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]
toolMessages: Anthropic.ToolResultBlockParam[]
}>(
(acc, part) => {
if (part.type === "tool_result") {
acc.toolMessages.push(part)
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part)
}
return acc
},
{ nonToolMessages: [], toolMessages: [] },
)
// Process tool result messages FIRST since they must follow the tool use messages
let toolResultImages: string[] = []
toolMessages.forEach((toolMessage) => {
// The Anthropic SDK allows tool results to be a string or an array of text and image blocks, enabling rich and structured content. In contrast, the Ollama SDK only supports tool results as a single string, so we map the Anthropic tool result parts into one concatenated string to maintain compatibility.
let content: string
if (typeof toolMessage.content === "string") {
content = toolMessage.content
} else {
content =
toolMessage.content
?.map((part) => {
if (part.type === "image") {
toolResultImages.push(`data:${part.source.media_type};base64,${part.source.data}`)
return "(see following user message for image)"
}
return part.text
})
.join("\n") ?? ""
}
ollamaMessages.push({
role: "user",
images: toolResultImages.length > 0 ? toolResultImages : undefined,
content: content,
})
})
// Process non-tool messages
if (nonToolMessages.length > 0) {
ollamaMessages.push({
role: "user",
content: nonToolMessages
.map((part) => {
if (part.type === "image") {
return `data:${part.source.media_type};base64,${part.source.data}`
}
return part.text
})
.join("\n"),
})
}
} else if (anthropicMessage.role === "assistant") {
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]
toolMessages: Anthropic.ToolUseBlockParam[]
}>(
(acc, part) => {
if (part.type === "tool_use") {
acc.toolMessages.push(part)
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part)
} // assistant cannot send tool_result messages
return acc
},
{ nonToolMessages: [], toolMessages: [] },
)
// Process non-tool messages
let content: string = ""
if (nonToolMessages.length > 0) {
content = nonToolMessages
.map((part) => {
if (part.type === "image") {
return "" // impossible as the assistant cannot send images
}
return part.text
})
.join("\n")
}
ollamaMessages.push({
role: "assistant",
content,
})
}
}
}
return ollamaMessages
}
-3
View File
@@ -161,7 +161,6 @@ export function convertToAnthropicMessage(completion: OpenAI.Chat.Completions.Ch
{
type: "text",
text: openAiMessage.content || "",
citations: null,
},
],
model: completion.model,
@@ -182,8 +181,6 @@ export function convertToAnthropicMessage(completion: OpenAI.Chat.Completions.Ch
usage: {
input_tokens: completion.usage?.prompt_tokens || 0,
output_tokens: completion.usage?.completion_tokens || 0,
cache_creation_input_tokens: null,
cache_read_input_tokens: null,
},
}
-151
View File
@@ -1,151 +0,0 @@
import { ModelInfo } from "../../shared/api"
import { convertToOpenAiMessages } from "./openai-format"
import { convertToR1Format } from "./r1-format"
import { ApiStream, ApiStreamChunk } from "./stream"
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { OpenRouterErrorResponse } from "../providers/types"
export async function createOpenRouterStream(
client: OpenAI,
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
model: { id: string; info: ModelInfo },
o3MiniReasoningEffort?: string,
thinkingBudgetTokens?: number,
) {
// Convert Anthropic messages to OpenAI format
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
...convertToOpenAiMessages(messages),
]
// prompt caching: https://openrouter.ai/docs/prompt-caching
// this is specifically for claude models (some models may 'support prompt caching' automatically without this)
switch (model.id) {
case "anthropic/claude-3.7-sonnet":
case "anthropic/claude-3.7-sonnet:beta":
case "anthropic/claude-3.7-sonnet:thinking":
case "anthropic/claude-3-7-sonnet":
case "anthropic/claude-3-7-sonnet:beta":
case "anthropic/claude-3.5-sonnet":
case "anthropic/claude-3.5-sonnet:beta":
case "anthropic/claude-3.5-sonnet-20240620":
case "anthropic/claude-3.5-sonnet-20240620:beta":
case "anthropic/claude-3-5-haiku":
case "anthropic/claude-3-5-haiku:beta":
case "anthropic/claude-3-5-haiku-20241022":
case "anthropic/claude-3-5-haiku-20241022:beta":
case "anthropic/claude-3-haiku":
case "anthropic/claude-3-haiku:beta":
case "anthropic/claude-3-opus":
case "anthropic/claude-3-opus:beta":
openAiMessages[0] = {
role: "system",
content: [
{
type: "text",
text: systemPrompt,
// @ts-ignore-next-line
cache_control: { type: "ephemeral" },
},
],
}
// Add cache_control to the last two user messages
// (note: this works because we only ever add one user message at a time, but if we added multiple we'd need to mark the user message before the last assistant message)
const lastTwoUserMessages = openAiMessages.filter((msg) => msg.role === "user").slice(-2)
lastTwoUserMessages.forEach((msg) => {
if (typeof msg.content === "string") {
msg.content = [{ type: "text", text: msg.content }]
}
if (Array.isArray(msg.content)) {
// NOTE: this is fine since env details will always be added at the end. but if it weren't there, and the user added a image_url type message, it would pop a text part before it and then move it after to the end.
let lastTextPart = msg.content.filter((part) => part.type === "text").pop()
if (!lastTextPart) {
lastTextPart = { type: "text", text: "..." }
msg.content.push(lastTextPart)
}
// @ts-ignore-next-line
lastTextPart["cache_control"] = { type: "ephemeral" }
}
})
break
default:
break
}
// Not sure how openrouter defaults max tokens when no value is provided, but the anthropic api requires this value and since they offer both 4096 and 8192 variants, we should ensure 8192.
// (models usually default to max tokens allowed)
let maxTokens: number | undefined
switch (model.id) {
case "anthropic/claude-3.7-sonnet":
case "anthropic/claude-3.7-sonnet:beta":
case "anthropic/claude-3.7-sonnet:thinking":
case "anthropic/claude-3-7-sonnet":
case "anthropic/claude-3-7-sonnet:beta":
case "anthropic/claude-3.5-sonnet":
case "anthropic/claude-3.5-sonnet:beta":
case "anthropic/claude-3.5-sonnet-20240620":
case "anthropic/claude-3.5-sonnet-20240620:beta":
case "anthropic/claude-3-5-haiku":
case "anthropic/claude-3-5-haiku:beta":
case "anthropic/claude-3-5-haiku-20241022":
case "anthropic/claude-3-5-haiku-20241022:beta":
maxTokens = 8_192
break
}
let temperature: number | undefined = 0
let topP: number | undefined = undefined
if (
model.id.startsWith("deepseek/deepseek-r1") ||
model.id === "perplexity/sonar-reasoning" ||
model.id === "qwen/qwq-32b:free" ||
model.id === "qwen/qwq-32b"
) {
// Recommended values from DeepSeek
temperature = 0.7
topP = 0.95
openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
}
let reasoning: { max_tokens: number } | undefined = undefined
switch (model.id) {
case "anthropic/claude-3.7-sonnet":
case "anthropic/claude-3.7-sonnet:beta":
case "anthropic/claude-3.7-sonnet:thinking":
case "anthropic/claude-3-7-sonnet":
case "anthropic/claude-3-7-sonnet:beta":
let budget_tokens = thinkingBudgetTokens || 0
const reasoningOn = budget_tokens !== 0 ? true : false
if (reasoningOn) {
temperature = undefined // extended thinking does not support non-1 temperature
reasoning = { max_tokens: budget_tokens }
}
break
}
// Removes messages in the middle when close to context window limit. Should not be applied to models that support prompt caching since it would continuously break the cache.
let shouldApplyMiddleOutTransform = !model.info.supportsPromptCache
// except for deepseek (which we set supportsPromptCache to true for), where because the context window is so small our truncation algo might miss and we should use openrouter's middle-out transform as a fallback to ensure we don't exceed the context window (FIXME: once we have a more robust token estimator we should not rely on this)
if (model.id === "deepseek/deepseek-chat") {
shouldApplyMiddleOutTransform = true
}
// @ts-ignore-next-line
const stream = await client.chat.completions.create({
model: model.id,
max_tokens: maxTokens,
temperature: temperature,
top_p: topP,
messages: openAiMessages,
stream: true,
transforms: shouldApplyMiddleOutTransform ? ["middle-out"] : undefined,
include_reasoning: true,
...(model.id === "openai/o3-mini" ? { reasoning_effort: o3MiniReasoningEffort || "medium" } : {}),
...(reasoning ? { reasoning } : {}),
})
return stream
}
-3
View File
@@ -175,7 +175,6 @@ export async function convertToAnthropicMessage(
return {
type: "text",
text: part.value,
citations: null,
}
}
@@ -196,8 +195,6 @@ export async function convertToAnthropicMessage(
usage: {
input_tokens: 0,
output_tokens: 0,
cache_creation_input_tokens: null,
cache_read_input_tokens: null,
},
}
}
+103
View File
@@ -0,0 +1,103 @@
import * as vscode from "vscode"
export type ApiProvider =
| "openai"
| "anthropic"
| "azure"
| "mistral"
| "deepseek"
| "qwen"
| "together"
| "litellm"
| "ollama"
| "lmstudio"
| "vertex"
| "requesty"
| "openrouter"
| "aws"
| "bedrock"
| "gemini"
| "openai-native"
| "vscode-lm"
export interface ModelInfo {
id: string
name: string
provider: string
contextWindow?: number
maxTokens?: number
supportsPromptCache?: boolean
supportsImages?: boolean
supportsComputerUse?: boolean
cacheWritesPrice?: number
cacheReadsPrice?: number
inputPrice?: number
outputPrice?: number
description?: string
}
export interface ApiHandlerOptions {
provider: ApiProvider
modelId: string
apiKey?: string
baseUrl?: string
}
export type AnthropicModelId = "claude-3-opus-20240229" | "claude-3-sonnet-20240229" | "claude-3-haiku-20240307"
export type BedrockModelId = "anthropic.claude-3-sonnet-20240229" | "anthropic.claude-3-haiku-20240307"
export type DeepSeekModelId = "deepseek-chat"
export type GeminiModelId = "gemini-pro" | "gemini-pro-vision"
export type OpenAiNativeModelId = "gpt-4-turbo-preview" | "gpt-4-vision-preview"
export type QwenModelId = "qwen-turbo" | "qwen-plus" | "qwen-max"
export type VertexModelId = "gemini-pro" | "gemini-pro-vision"
export interface ApiConfiguration {
apiModelId?: string
apiProvider: ApiProvider
apiKey?: string
openRouterApiKey?: string
openRouterModelId?: string
openRouterModelInfo?: ModelInfo
awsAccessKey?: string
awsSecretKey?: string
awsSessionToken?: string
awsRegion?: string
awsUseCrossRegionInference?: boolean
awsProfile?: string
awsUseProfile?: boolean
anthropicApiKey?: string
anthropicBaseUrl?: string
azureApiKey?: string
azureEndpoint?: string
azureDeploymentName?: string
azureApiVersion?: string
mistralApiKey?: string
deepSeekApiKey?: string
qwenApiKey?: string
qwenEndpoint?: string
qwenApiLine?: string
togetherApiKey?: string
togetherModelId?: string
liteLlmApiKey?: string
liteLlmBaseUrl?: string
liteLlmModelId?: string
ollamaBaseUrl?: string
ollamaModelId?: string
lmStudioBaseUrl?: string
lmStudioModelId?: string
vertexProjectId?: string
vertexLocation?: string
vertexEndpoint?: string
vertexModelId?: string
vertexRegion?: string
requestyApiKey?: string
requestyEndpoint?: string
requestyModelId?: string
openAiBaseUrl?: string
openAiApiKey?: string
openAiModelId?: string
openAiModelInfo?: ModelInfo
openAiNativeApiKey?: string
geminiApiKey?: string
vsCodeLmModelSelector?: string
}
+152 -299
View File
@@ -9,10 +9,12 @@ import * as path from "path"
import { serializeError } from "serialize-error"
import * as vscode from "vscode"
import { ApiHandler, buildApiHandler } from "../api"
import { OpenAiHandler } from "../api/providers/openai"
import { OpenRouterHandler } from "../api/providers/openrouter"
import { ApiStream } from "../api/transform/stream"
import CheckpointTracker from "../integrations/checkpoints/CheckpointTracker"
import { DIFF_VIEW_URI_SCHEME, DiffViewProvider } from "../integrations/editor/DiffViewProvider"
import { formatContentBlockToMarkdown } from "../integrations/misc/export-markdown"
import { findToolName, formatContentBlockToMarkdown } from "../integrations/misc/export-markdown"
import { extractTextFromFile } from "../integrations/misc/extract-text"
import { showSystemNotification } from "../integrations/notifications"
import { TerminalManager } from "../integrations/terminal/TerminalManager"
@@ -22,7 +24,7 @@ import { listFiles } from "../services/glob/list-files"
import { regexSearchFiles } from "../services/ripgrep"
import { parseSourceCodeForDefinitionsTopLevel } from "../services/tree-sitter"
import { ApiConfiguration } from "../shared/api"
import { findLast, findLastIndex, parsePartialArrayString } from "../shared/array"
import { findLast, findLastIndex } from "../shared/array"
import { AutoApprovalSettings } from "../shared/AutoApprovalSettings"
import { BrowserSettings } from "../shared/BrowserSettings"
import { ChatSettings } from "../shared/ChatSettings"
@@ -35,10 +37,8 @@ import {
ClineApiReqCancelReason,
ClineApiReqInfo,
ClineAsk,
ClineAskQuestion,
ClineAskUseMcpServer,
ClineMessage,
ClinePlanModeResponse,
ClineSay,
ClineSayBrowserAction,
ClineSayTool,
@@ -47,8 +47,8 @@ import {
import { getApiMetrics } from "../shared/getApiMetrics"
import { HistoryItem } from "../shared/HistoryItem"
import { ClineAskResponse, ClineCheckpointRestore } from "../shared/WebviewMessage"
import { calculateApiCostAnthropic } from "../utils/cost"
import { fileExistsAtPath, isDirectory } from "../utils/fs"
import { calculateApiCost } from "../utils/cost"
import { fileExistsAtPath } from "../utils/fs"
import { arePathsEqual, getReadablePath } from "../utils/path"
import { fixModelHtmlEscaping, removeInvalidChars } from "../utils/string"
import { AssistantMessageContent, parseAssistantMessage, ToolParamName, ToolUseName } from "./assistant-message"
@@ -58,22 +58,17 @@ import { parseMentions } from "./mentions"
import { formatResponse } from "./prompts/responses"
import { addUserInstructions, SYSTEM_PROMPT } from "./prompts/system"
import { getNextTruncationRange, getTruncatedMessages } from "./sliding-window"
import { OpenAiHandler } from "../api/providers/openai"
import { ApiStream } from "../api/transform/stream"
import { ClineHandler } from "../api/providers/cline"
import { ClineProvider, GlobalFileNames } from "./webview/ClineProvider"
import { DEFAULT_LANGUAGE_SETTINGS, getLanguageKey, LanguageDisplay, LanguageKey } from "../shared/Languages"
import { telemetryService } from "../services/telemetry/TelemetryService"
import pTimeout from "p-timeout"
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) ?? path.join(os.homedir(), "Desktop") // may or may not exist but fs checking existence would immediately ask for permission which would be bad UX, need to come up with a better solution
type ToolResponse = string | Array<Anthropic.TextBlockParam | Anthropic.ImageBlockParam>
type UserContent = Array<Anthropic.ContentBlockParam>
type UserContent = Array<
Anthropic.TextBlockParam | Anthropic.ImageBlockParam | Anthropic.ToolUseBlockParam | Anthropic.ToolResultBlockParam
>
export class Cline {
readonly taskId: string
readonly apiProvider?: string
api: ApiHandler
private terminalManager: TerminalManager
private urlContentFetcher: UrlContentFetcher
@@ -134,7 +129,6 @@ export class Cline {
console.error("Failed to initialize ClineIgnoreController:", error)
})
this.providerRef = new WeakRef(provider)
this.apiProvider = apiConfiguration.apiProvider
this.api = buildApiHandler(apiConfiguration)
this.terminalManager = new TerminalManager()
this.urlContentFetcher = new UrlContentFetcher(provider.context)
@@ -154,14 +148,6 @@ export class Cline {
} else {
throw new Error("Either historyItem or task/images must be provided")
}
if (historyItem) {
// Open task from history
telemetryService.captureTaskRestarted(this.taskId, this.apiProvider)
} else {
// New task started
telemetryService.captureTaskCreated(this.taskId, this.apiProvider)
}
}
updateBrowserSettings(browserSettings: BrowserSettings) {
@@ -297,12 +283,10 @@ export class Cline {
break
case "taskAndWorkspace":
case "workspace":
if (!this.checkpointTracker && !this.checkpointTrackerErrorMessage) {
if (!this.checkpointTracker) {
try {
this.checkpointTracker = await CheckpointTracker.create(
this.taskId,
this.providerRef.deref()?.context.globalStorageUri.fsPath,
)
this.checkpointTracker = await CheckpointTracker.create(this.taskId, this.providerRef.deref())
this.checkpointTrackerErrorMessage = undefined
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error"
console.error("Failed to initialize checkpoint tracker:", errorMessage)
@@ -369,17 +353,15 @@ export class Cline {
break
}
if (restoreType !== "task") {
// Set isCheckpointCheckedOut flag on the message
// Find all checkpoint messages before this one
const checkpointMessages = this.clineMessages.filter((m) => m.say === "checkpoint_created")
const currentMessageIndex = checkpointMessages.findIndex((m) => m.ts === messageTs)
// Set isCheckpointCheckedOut flag on the message
// Find all checkpoint messages before this one
const checkpointMessages = this.clineMessages.filter((m) => m.say === "checkpoint_created")
const currentMessageIndex = checkpointMessages.findIndex((m) => m.ts === messageTs)
// Set isCheckpointCheckedOut to false for all checkpoint messages
checkpointMessages.forEach((m, i) => {
m.isCheckpointCheckedOut = i === currentMessageIndex
})
}
// Set isCheckpointCheckedOut to false for all checkpoint messages
checkpointMessages.forEach((m, i) => {
m.isCheckpointCheckedOut = i === currentMessageIndex
})
await this.saveClineMessages()
@@ -412,12 +394,10 @@ export class Cline {
}
// TODO: handle if this is called from outside original workspace, in which case we need to show user error message we cant show diff outside of workspace?
if (!this.checkpointTracker && !this.checkpointTrackerErrorMessage) {
if (!this.checkpointTracker) {
try {
this.checkpointTracker = await CheckpointTracker.create(
this.taskId,
this.providerRef.deref()?.context.globalStorageUri.fsPath,
)
this.checkpointTracker = await CheckpointTracker.create(this.taskId, this.providerRef.deref())
this.checkpointTrackerErrorMessage = undefined
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error"
console.error("Failed to initialize checkpoint tracker:", errorMessage)
@@ -441,30 +421,21 @@ export class Cline {
try {
if (seeNewChangesSinceLastTaskCompletion) {
// Get last task completed
const lastTaskCompletedMessageCheckpointHash = findLast(
const lastTaskCompletedMessage = findLast(
this.clineMessages.slice(0, messageIndex),
(m) => m.say === "completion_result",
)?.lastCheckpointHash // ask is only used to relinquish control, its the last say we care about
) // ask is only used to relinquish control, its the last say we care about
// if undefined, then we get diff from beginning of git
// if (!lastTaskCompletedMessage) {
// console.error("No previous task completion message found")
// return
// }
// This value *should* always exist
const firstCheckpointMessageCheckpointHash = this.clineMessages.find(
(m) => m.say === "checkpoint_created",
)?.lastCheckpointHash
const previousCheckpointHash = lastTaskCompletedMessageCheckpointHash || firstCheckpointMessageCheckpointHash // either use the diff between the first checkpoint and the task completion, or the diff between the latest two task completions
if (!previousCheckpointHash) {
vscode.window.showErrorMessage("Unexpected error: No checkpoint hash found")
relinquishButton()
return
}
// Get changed files between current state and commit
changedFiles = await this.checkpointTracker?.getDiffSet(previousCheckpointHash, hash)
changedFiles = await this.checkpointTracker?.getDiffSet(
lastTaskCompletedMessage?.lastCheckpointHash, // if undefined, then we get diff from beginning of git history, AKA when the task was started
hash,
)
if (!changedFiles?.length) {
vscode.window.showInformationMessage("No changes found")
relinquishButton()
@@ -527,12 +498,10 @@ export class Cline {
return false
}
if (!this.checkpointTracker && !this.checkpointTrackerErrorMessage) {
if (!this.checkpointTracker) {
try {
this.checkpointTracker = await CheckpointTracker.create(
this.taskId,
this.providerRef.deref()?.context.globalStorageUri.fsPath,
)
this.checkpointTracker = await CheckpointTracker.create(this.taskId, this.providerRef.deref())
this.checkpointTrackerErrorMessage = undefined
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error"
console.error("Failed to initialize checkpoint tracker:", errorMessage)
@@ -544,26 +513,12 @@ export class Cline {
const lastTaskCompletedMessage = findLast(this.clineMessages.slice(0, messageIndex), (m) => m.say === "completion_result")
try {
// Get last task completed
const lastTaskCompletedMessageCheckpointHash = lastTaskCompletedMessage?.lastCheckpointHash // ask is only used to relinquish control, its the last say we care about
// if undefined, then we get diff from beginning of git
// if (!lastTaskCompletedMessage) {
// console.error("No previous task completion message found")
// return
// }
// This value *should* always exist
const firstCheckpointMessageCheckpointHash = this.clineMessages.find(
(m) => m.say === "checkpoint_created",
)?.lastCheckpointHash
const previousCheckpointHash = lastTaskCompletedMessageCheckpointHash || firstCheckpointMessageCheckpointHash // either use the diff between the first checkpoint and the task completion, or the diff between the latest two task completions
if (!previousCheckpointHash) {
return false
}
// Get count of changed files between current state and commit
const changedFilesCount = (await this.checkpointTracker?.getDiffCount(previousCheckpointHash, hash)) || 0
// Get changed files between current state and commit
const changedFiles = await this.checkpointTracker?.getDiffSet(
lastTaskCompletedMessage?.lastCheckpointHash, // if undefined, then we get diff from beginning of git history, AKA when the task was started
hash,
)
const changedFilesCount = changedFiles?.length || 0
if (changedFilesCount > 0) {
return true
}
@@ -831,8 +786,7 @@ export class Cline {
}
private async resumeTaskFromHistory() {
// UPDATE: we don't need this anymore since most tasks are now created with checkpoints enabled
// right now we let users init checkpoints for old tasks, assuming they're continuing them from the same workspace (which we never tied to tasks, so no way for us to know if it's opened in the right workspace)
// TODO: right now we let users init checkpoints for old tasks, assuming they're continuing them from the same workspace (which we never tied to tasks, so no way for us to know if it's opened in the right workspace)
// const doesShadowGitExist = await CheckpointTracker.doesShadowGitExist(this.taskId, this.providerRef.deref())
// if (!doesShadowGitExist) {
// this.checkpointTrackerErrorMessage = "Checkpoints are only available for new tasks"
@@ -904,7 +858,42 @@ export class Cline {
// need to make sure that the api conversation history can be resumed by the api, even if it goes out of sync with cline messages
const existingApiConversationHistory: Anthropic.Messages.MessageParam[] = await this.getSavedApiConversationHistory()
let existingApiConversationHistory: Anthropic.Messages.MessageParam[] = await this.getSavedApiConversationHistory()
// v2.0 xml tags refactor caveat: since we don't use tools anymore, we need to replace all tool use blocks with a text block since the API disallows conversations with tool uses and no tool schema
const conversationWithoutToolBlocks = existingApiConversationHistory.map((message) => {
if (Array.isArray(message.content)) {
const newContent = message.content.map((block) => {
if (block.type === "tool_use") {
// it's important we convert to the new tool schema format so the model doesn't get confused about how to invoke tools
const inputAsXml = Object.entries(block.input as Record<string, string>)
.map(([key, value]) => `<${key}>\n${value}\n</${key}>`)
.join("\n")
return {
type: "text",
text: `<${block.name}>\n${inputAsXml}\n</${block.name}>`,
} as Anthropic.Messages.TextBlockParam
} else if (block.type === "tool_result") {
// Convert block.content to text block array, removing images
const contentAsTextBlocks = Array.isArray(block.content)
? block.content.filter((item) => item.type === "text")
: [{ type: "text", text: block.content }]
const textContent = contentAsTextBlocks.map((item) => item.text).join("\n\n")
const toolName = findToolName(block.tool_use_id, existingApiConversationHistory)
return {
type: "text",
text: `[${toolName} Result]\n\n${textContent}`,
} as Anthropic.Messages.TextBlockParam
}
return block
})
return { ...message, content: newContent }
}
return message
})
existingApiConversationHistory = conversationWithoutToolBlocks
// FIXME: remove tool use blocks altogether
// if the last message is an assistant message, we need to check if there's tool use since every tool use has to have a tool response
// if there's no tool use and only a text block, then we can just add a user message
@@ -1086,74 +1075,67 @@ export class Cline {
// Checkpoints
async saveCheckpoint(isAttemptCompletionMessage: boolean = false) {
const commitHash = await this.checkpointTracker?.commit() // silently fails for now
// Set isCheckpointCheckedOut to false for all checkpoint_created messages
this.clineMessages.forEach((message) => {
if (message.say === "checkpoint_created") {
message.isCheckpointCheckedOut = false
}
})
if (!isAttemptCompletionMessage) {
// For non-attempt completion we just say checkpoints
await this.say("checkpoint_created")
this.checkpointTracker?.commit().then(async (commitHash) => {
if (commitHash) {
if (!isAttemptCompletionMessage) {
// For non-attempt completion we just say checkpoints
await this.say("checkpoint_created", commitHash)
const lastCheckpointMessage = findLast(this.clineMessages, (m) => m.say === "checkpoint_created")
if (lastCheckpointMessage) {
lastCheckpointMessage.lastCheckpointHash = commitHash
await this.saveClineMessages()
}
}) // silently fails for now
//
} else {
// attempt completion requires checkpoint to be sync so that we can present button after attempt_completion
const commitHash = await this.checkpointTracker?.commit()
// For attempt_completion, find the last completion_result message and set its checkpoint hash. This will be used to present the 'see new changes' button
const lastCompletionResultMessage = findLast(
this.clineMessages,
(m) => m.say === "completion_result" || m.ask === "completion_result",
)
if (lastCompletionResultMessage) {
lastCompletionResultMessage.lastCheckpointHash = commitHash
await this.saveClineMessages()
} else {
// For attempt_completion, find the last completion_result message and set its checkpoint hash. This will be used to present the 'see new changes' button
const lastCompletionResultMessage = findLast(
this.clineMessages,
(m) => m.say === "completion_result" || m.ask === "completion_result",
)
if (lastCompletionResultMessage) {
lastCompletionResultMessage.lastCheckpointHash = commitHash
await this.saveClineMessages()
}
}
// Previously we checkpointed every message, but this is excessive and unnecessary.
// // Start from the end and work backwards until we find a tool use or another message with a hash
// for (let i = this.clineMessages.length - 1; i >= 0; i--) {
// const message = this.clineMessages[i]
// if (message.lastCheckpointHash) {
// // Found a message with a hash, so we can stop
// break
// }
// // Update this message with a hash
// message.lastCheckpointHash = commitHash
// // We only care about adding the hash to the last tool use (we don't want to add this hash to every prior message ie for tasks pre-checkpoint)
// const isToolUse =
// message.say === "tool" ||
// message.ask === "tool" ||
// message.say === "command" ||
// message.ask === "command" ||
// message.say === "completion_result" ||
// message.ask === "completion_result" ||
// message.ask === "followup" ||
// message.say === "use_mcp_server" ||
// message.ask === "use_mcp_server" ||
// message.say === "browser_action" ||
// message.say === "browser_action_launch" ||
// message.ask === "browser_action_launch"
// if (isToolUse) {
// break
// }
// }
// // Save the updated messages
// await this.saveClineMessages()
}
// if (commitHash) {
// Previously we checkpointed every message, but this is excessive and unnecessary.
// // Start from the end and work backwards until we find a tool use or another message with a hash
// for (let i = this.clineMessages.length - 1; i >= 0; i--) {
// const message = this.clineMessages[i]
// if (message.lastCheckpointHash) {
// // Found a message with a hash, so we can stop
// break
// }
// // Update this message with a hash
// message.lastCheckpointHash = commitHash
// // We only care about adding the hash to the last tool use (we don't want to add this hash to every prior message ie for tasks pre-checkpoint)
// const isToolUse =
// message.say === "tool" ||
// message.ask === "tool" ||
// message.say === "command" ||
// message.ask === "command" ||
// message.say === "completion_result" ||
// message.ask === "completion_result" ||
// message.ask === "followup" ||
// message.say === "use_mcp_server" ||
// message.ask === "use_mcp_server" ||
// message.say === "browser_action" ||
// message.say === "browser_action_launch" ||
// message.ask === "browser_action_launch"
// if (isToolUse) {
// break
// }
// }
// // Save the updated messages
// await this.saveClineMessages()
// }
}
// Tools
@@ -1285,43 +1267,16 @@ export class Cline {
let systemPrompt = await SYSTEM_PROMPT(cwd, supportsComputerUse, mcpHub, this.browserSettings)
let settingsCustomInstructions = this.customInstructions?.trim()
const preferredLanguage = getLanguageKey(
vscode.workspace.getConfiguration("cline").get<LanguageDisplay>("preferredLanguage"),
)
const preferredLanguageInstructions =
preferredLanguage && preferredLanguage !== DEFAULT_LANGUAGE_SETTINGS
? `# Preferred Language\n\nSpeak in ${preferredLanguage}.`
: ""
const clineRulesFilePath = path.resolve(cwd, GlobalFileNames.clineRules)
let clineRulesFileInstructions: string | undefined
if (await fileExistsAtPath(clineRulesFilePath)) {
if (await isDirectory(clineRulesFilePath)) {
try {
// Read all files in the .clinerules/ directory.
const ruleFiles = await fs
.readdir(clineRulesFilePath, { withFileTypes: true, recursive: true })
.then((files) => files.filter((file) => file.isFile()))
.then((files) => files.map((file) => path.resolve(file.parentPath, file.name)))
const ruleFileContent = await Promise.all(
ruleFiles.map(async (file) => {
const ruleFilePath = path.resolve(clineRulesFilePath, file)
const ruleFilePathRelative = path.relative(cwd, ruleFilePath)
return `${ruleFilePathRelative}\n` + (await fs.readFile(ruleFilePath, "utf8")).trim()
}),
).then((contents) => contents.join("\n\n"))
clineRulesFileInstructions = `# .clinerules/\n\nThe following is provided by a root-level .clinerules/ directory where the user has specified instructions for this working directory (${cwd.toPosix()})\n\n${ruleFileContent}`
} catch {
console.error(`Failed to read .clinerules directory at ${clineRulesFilePath}`)
}
} else {
try {
const ruleFileContent = (await fs.readFile(clineRulesFilePath, "utf8")).trim()
if (ruleFileContent) {
clineRulesFileInstructions = `# .clinerules\n\nThe following is provided by a root-level .clinerules file where the user has specified instructions for this working directory (${cwd.toPosix()})\n\n${ruleFileContent}`
}
} catch {
console.error(`Failed to read .clinerules file at ${clineRulesFilePath}`)
try {
const ruleFileContent = (await fs.readFile(clineRulesFilePath, "utf8")).trim()
if (ruleFileContent) {
clineRulesFileInstructions = `# .clinerules\n\nThe following is provided by a root-level .clinerules file where the user has specified instructions for this working directory (${cwd.toPosix()})\n\n${ruleFileContent}`
}
} catch {
console.error(`Failed to read .clinerules file at ${clineRulesFilePath}`)
}
}
@@ -1331,19 +1286,9 @@ export class Cline {
clineIgnoreInstructions = `# .clineignore\n\n(The following is provided by a root-level .clineignore file where the user has specified files and directories that should not be accessed. When using list_files, you'll notice a ${LOCK_TEXT_SYMBOL} next to files that are blocked. Attempting to access the file's contents e.g. through read_file will result in an error.)\n\n${clineIgnoreContent}\n.clineignore`
}
if (
settingsCustomInstructions ||
clineRulesFileInstructions ||
clineIgnoreInstructions ||
preferredLanguageInstructions
) {
if (settingsCustomInstructions || clineRulesFileInstructions) {
// altering the system prompt mid-task will break the prompt cache, but in the grand scheme this will not change often so it's better to not pollute user messages with it the way we have to with <potentially relevant details>
systemPrompt += addUserInstructions(
settingsCustomInstructions,
clineRulesFileInstructions,
clineIgnoreInstructions,
preferredLanguageInstructions,
)
systemPrompt += addUserInstructions(settingsCustomInstructions, clineRulesFileInstructions, clineIgnoreInstructions)
}
// If the previous API request's total token usage is close to the context window, truncate the conversation history to free up space for the new request
@@ -1408,7 +1353,7 @@ export class Cline {
yield firstChunk.value
this.isWaitingForFirstChunk = false
} catch (error) {
const isOpenRouter = this.api instanceof OpenRouterHandler || this.api instanceof ClineHandler
const isOpenRouter = this.api instanceof OpenRouterHandler
if (isOpenRouter && !this.didAutomaticallyRetryFailedApiRequest) {
console.log("first chunk failed, waiting 1 second before retrying")
await delay(1000)
@@ -1746,7 +1691,7 @@ export class Cline {
`This is likely because the SEARCH block content doesn't match exactly with what's in the file, or if you used multiple SEARCH/REPLACE blocks they may not have been in the order they appear in the file.\n\n` +
`The file was reverted to its original state:\n\n` +
`<file_content path="${relPath.toPosix()}">\n${this.diffViewProvider.originalContent}\n</file_content>\n\n` +
`Try again with fewer/more precise SEARCH blocks.\n(If you run into this error two times in a row, you may use the write_to_file tool as a fallback.)`,
`Try again with a more precise SEARCH block.\n(If you keep running into this error, you may use the write_to_file tool as a workaround.)`,
),
)
await this.diffViewProvider.revertChanges()
@@ -1855,7 +1800,6 @@ export class Cline {
this.removeLastPartialMessageIfExistsWithType("ask", "tool")
await this.say("tool", completeMessage, undefined, false)
this.consecutiveAutoApprovedRequestsCount++
telemetryService.captureToolUsage(this.taskId, block.name, true, true)
// we need an artificial delay to let the diagnostics catch up to the changes
await delay(3_500)
@@ -1865,6 +1809,7 @@ export class Cline {
`Cline wants to ${fileExists ? "edit" : "create"} ${path.basename(relPath)}`,
)
this.removeLastPartialMessageIfExistsWithType("say", "tool")
// const didApprove = await askApproval("tool", completeMessage)
// Need a more customized tool response for file edits to highlight the fact that the file was not updated (particularly important for deepseek)
let didApprove = true
@@ -1882,18 +1827,17 @@ export class Cline {
}
this.didRejectTool = true
didApprove = false
telemetryService.captureToolUsage(this.taskId, block.name, false, false)
} else {
// User hit the approve button, and may have provided feedback
if (text || images?.length) {
pushAdditionalToolFeedback(text, images)
await this.say("user_feedback", text, images)
}
telemetryService.captureToolUsage(this.taskId, block.name, false, true)
}
if (!didApprove) {
await this.diffViewProvider.revertChanges()
break
}
}
@@ -2001,7 +1945,6 @@ export class Cline {
this.removeLastPartialMessageIfExistsWithType("ask", "tool")
await this.say("tool", completeMessage, undefined, false) // need to be sending partialValue bool, since undefined has its own purpose in that the message is treated neither as a partial or completion of a partial, but as a single complete message
this.consecutiveAutoApprovedRequestsCount++
telemetryService.captureToolUsage(this.taskId, block.name, true, true)
} else {
showNotificationForApprovalIfAutoApprovalEnabled(
`Cline wants to read ${path.basename(absolutePath)}`,
@@ -2009,10 +1952,8 @@ export class Cline {
this.removeLastPartialMessageIfExistsWithType("say", "tool")
const didApprove = await askApproval("tool", completeMessage)
if (!didApprove) {
telemetryService.captureToolUsage(this.taskId, block.name, false, false)
break
}
telemetryService.captureToolUsage(this.taskId, block.name, false, true)
}
// now execute the tool like normal
const content = await extractTextFromFile(absolutePath)
@@ -2075,7 +2016,6 @@ export class Cline {
this.removeLastPartialMessageIfExistsWithType("ask", "tool")
await this.say("tool", completeMessage, undefined, false)
this.consecutiveAutoApprovedRequestsCount++
telemetryService.captureToolUsage(this.taskId, block.name, true, true)
} else {
showNotificationForApprovalIfAutoApprovalEnabled(
`Cline wants to view directory ${path.basename(absolutePath)}/`,
@@ -2083,10 +2023,8 @@ export class Cline {
this.removeLastPartialMessageIfExistsWithType("say", "tool")
const didApprove = await askApproval("tool", completeMessage)
if (!didApprove) {
telemetryService.captureToolUsage(this.taskId, block.name, false, false)
break
}
telemetryService.captureToolUsage(this.taskId, block.name, false, true)
}
pushToolResult(result)
@@ -2142,7 +2080,6 @@ export class Cline {
this.removeLastPartialMessageIfExistsWithType("ask", "tool")
await this.say("tool", completeMessage, undefined, false)
this.consecutiveAutoApprovedRequestsCount++
telemetryService.captureToolUsage(this.taskId, block.name, true, true)
} else {
showNotificationForApprovalIfAutoApprovalEnabled(
`Cline wants to view source code definitions in ${path.basename(absolutePath)}/`,
@@ -2150,10 +2087,8 @@ export class Cline {
this.removeLastPartialMessageIfExistsWithType("say", "tool")
const didApprove = await askApproval("tool", completeMessage)
if (!didApprove) {
telemetryService.captureToolUsage(this.taskId, block.name, false, false)
break
}
telemetryService.captureToolUsage(this.taskId, block.name, false, true)
}
pushToolResult(result)
@@ -2221,7 +2156,6 @@ export class Cline {
this.removeLastPartialMessageIfExistsWithType("ask", "tool")
await this.say("tool", completeMessage, undefined, false)
this.consecutiveAutoApprovedRequestsCount++
telemetryService.captureToolUsage(this.taskId, block.name, true, true)
} else {
showNotificationForApprovalIfAutoApprovalEnabled(
`Cline wants to search files in ${path.basename(absolutePath)}/`,
@@ -2229,10 +2163,8 @@ export class Cline {
this.removeLastPartialMessageIfExistsWithType("say", "tool")
const didApprove = await askApproval("tool", completeMessage)
if (!didApprove) {
telemetryService.captureToolUsage(this.taskId, block.name, false, false)
break
}
telemetryService.captureToolUsage(this.taskId, block.name, false, true)
}
pushToolResult(results)
@@ -2720,14 +2652,9 @@ export class Cline {
}
case "ask_followup_question": {
const question: string | undefined = block.params.question
const optionsRaw: string | undefined = block.params.options
const sharedMessage = {
question: removeClosingTag("question", question),
options: parsePartialArrayString(removeClosingTag("options", optionsRaw)),
} satisfies ClineAskQuestion
try {
if (block.partial) {
await this.ask("followup", JSON.stringify(sharedMessage), block.partial).catch(() => {})
await this.ask("followup", removeClosingTag("question", question), block.partial).catch(() => {})
break
} else {
if (!question) {
@@ -2745,25 +2672,8 @@ export class Cline {
})
}
const { text, images } = await this.ask("followup", JSON.stringify(sharedMessage), false)
// Check if options contains the text response
if (optionsRaw && text && parsePartialArrayString(optionsRaw).includes(text)) {
// Valid option selected, don't show user message in UI
// Update last followup message with selected option
const lastFollowupMessage = findLast(this.clineMessages, (m) => m.ask === "followup")
if (lastFollowupMessage) {
lastFollowupMessage.text = JSON.stringify({
...sharedMessage,
selected: text,
} satisfies ClineAskQuestion)
await this.saveClineMessages()
}
} else {
// Option not selected, send user feedback
await this.say("user_feedback", text ?? "", images)
}
const { text, images } = await this.ask("followup", question, false)
await this.say("user_feedback", text ?? "", images)
pushToolResult(formatResponse.toolResult(`<answer>\n${text}\n</answer>`, images))
break
@@ -2776,14 +2686,11 @@ export class Cline {
}
case "plan_mode_response": {
const response: string | undefined = block.params.response
const optionsRaw: string | undefined = block.params.options
const sharedMessage = {
response: removeClosingTag("response", response),
options: parsePartialArrayString(removeClosingTag("options", optionsRaw)),
} satisfies ClinePlanModeResponse
try {
if (block.partial) {
await this.ask("plan_mode_response", JSON.stringify(sharedMessage), block.partial).catch(() => {})
await this.ask("plan_mode_response", removeClosingTag("response", response), block.partial).catch(
() => {},
)
break
} else {
if (!response) {
@@ -2802,7 +2709,7 @@ export class Cline {
// }
this.isAwaitingPlanResponse = true
let { text, images } = await this.ask("plan_mode_response", JSON.stringify(sharedMessage), false)
let { text, images } = await this.ask("plan_mode_response", response, false)
this.isAwaitingPlanResponse = false
// webview invoke sendMessage will send this marker in order to put webview into the proper state (responding to an ask) and as a flag to extension that the user switched to ACT mode.
@@ -2810,25 +2717,6 @@ export class Cline {
text = ""
}
// Check if options contains the text response
if (optionsRaw && text && parsePartialArrayString(optionsRaw).includes(text)) {
// Valid option selected, don't show user message in UI
// Update last followup message with selected option
const lastPlanMessage = findLast(this.clineMessages, (m) => m.ask === "plan_mode_response")
if (lastPlanMessage) {
lastPlanMessage.text = JSON.stringify({
...sharedMessage,
selected: text,
} satisfies ClinePlanModeResponse)
await this.saveClineMessages()
}
} else {
// Option not selected, send user feedback
if (text || images?.length) {
await this.say("user_feedback", text ?? "", images)
}
}
if (this.didRespondToPlanAskBySwitchingMode) {
pushToolResult(
formatResponse.toolResult(
@@ -2844,6 +2732,10 @@ export class Cline {
pushToolResult(formatResponse.toolResult(`<user_message>\n${text}\n</user_message>`, images))
}
if (text || images?.length) {
await this.say("user_feedback", text ?? "", images)
}
//
break
}
@@ -2948,7 +2840,6 @@ export class Cline {
await this.say("completion_result", result, undefined, false)
await this.saveCheckpoint(true)
await addNewChangesFlagToLastCompletionResultMessage()
telemetryService.captureTaskCompleted(this.taskId)
} else {
// we already sent a command message, meaning the complete completion message has also been sent
await this.saveCheckpoint(true)
@@ -2971,7 +2862,6 @@ export class Cline {
await this.say("completion_result", result, undefined, false)
await this.saveCheckpoint(true)
await addNewChangesFlagToLastCompletionResultMessage()
telemetryService.captureTaskCompleted(this.taskId)
}
// we already sent completion_result says, an empty string asks relinquishes control over button and field
@@ -3068,7 +2958,7 @@ export class Cline {
"mistake_limit_reached",
this.api.getModel().id.includes("claude")
? `This may indicate a failure in his thought process or inability to use a tool properly, which can be mitigated with some user guidance (e.g. "Try breaking down the task into smaller steps").`
: "Cline uses complex prompts and iterative task execution that may be challenging for less capable models. For best results, it's recommended to use Claude 3.7 Sonnet for its advanced agentic coding capabilities.",
: "Cline uses complex prompts and iterative task execution that may be challenging for less capable models. For best results, it's recommended to use Claude 3.5 Sonnet for its advanced agentic coding capabilities.",
)
if (response === "messageResponse") {
userContent.push(
@@ -3123,16 +3013,10 @@ export class Cline {
// use this opportunity to initialize the checkpoint tracker (can be expensive to initialize in the constructor)
// FIXME: right now we're letting users init checkpoints for old tasks, but this could be a problem if opening a task in the wrong workspace
// isNewTask &&
if (!this.checkpointTracker && !this.checkpointTrackerErrorMessage) {
if (!this.checkpointTracker) {
try {
this.checkpointTracker = await pTimeout(
CheckpointTracker.create(this.taskId, this.providerRef.deref()?.context.globalStorageUri.fsPath),
{
milliseconds: 15_000,
message:
"Checkpoints taking too long to initialize. Consider re-opening Cline in a project that uses git, or disabling checkpoints.",
},
)
this.checkpointTracker = await CheckpointTracker.create(this.taskId, this.providerRef.deref())
this.checkpointTrackerErrorMessage = undefined
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error"
console.error("Failed to initialize checkpoint tracker:", errorMessage)
@@ -3160,8 +3044,6 @@ export class Cline {
content: userContent,
})
telemetryService.captureConversationTurnEvent(this.taskId, this.apiProvider, this.api.getModel().id, "user")
// since we sent off a placeholder api_req_started message to update the webview while waiting to actually start the API request (to load potential details for example), we need to update the text of that message
const lastApiReqIndex = findLastIndex(this.clineMessages, (m) => m.say === "api_req_started")
this.clineMessages[lastApiReqIndex].text = JSON.stringify({
@@ -3189,13 +3071,7 @@ export class Cline {
cacheReads: cacheReadTokens,
cost:
totalCost ??
calculateApiCostAnthropic(
this.api.getModel().info,
inputTokens,
outputTokens,
cacheWriteTokens,
cacheReadTokens,
),
calculateApiCost(this.api.getModel().info, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens),
cancelReason,
streamingFailedMessage,
} satisfies ClineApiReqInfo)
@@ -3237,8 +3113,6 @@ export class Cline {
updateApiReqMsg(cancelReason, streamingFailedMessage)
await this.saveClineMessages()
telemetryService.captureConversationTurnEvent(this.taskId, this.apiProvider, this.api.getModel().id, "assistant")
// signals to provider that it can retrieve the saved messages from disk, as abortTask can not be awaited on in nature
this.didFinishAbortingStream = true
}
@@ -3260,7 +3134,6 @@ export class Cline {
let assistantMessage = ""
let reasoningMessage = ""
this.isStreaming = true
let didReceiveUsageChunk = false
try {
for await (const chunk of stream) {
if (!chunk) {
@@ -3268,7 +3141,6 @@ export class Cline {
}
switch (chunk.type) {
case "usage":
didReceiveUsageChunk = true
inputTokens += chunk.inputTokens
outputTokens += chunk.outputTokens
cacheWriteTokens += chunk.cacheWriteTokens ?? 0
@@ -3338,23 +3210,6 @@ export class Cline {
this.isStreaming = false
}
// OpenRouter/Cline may not return token usage as part of the stream (since it may abort early), so we fetch after the stream is finished
// (updateApiReq below will update the api_req_started message with the usage details. we do this async so it updates the api_req_started message in the background)
if (!didReceiveUsageChunk) {
this.api.getApiStreamUsage?.().then(async (apiStreamUsage) => {
if (apiStreamUsage) {
inputTokens += apiStreamUsage.inputTokens
outputTokens += apiStreamUsage.outputTokens
cacheWriteTokens += apiStreamUsage.cacheWriteTokens ?? 0
cacheReadTokens += apiStreamUsage.cacheReadTokens ?? 0
totalCost = apiStreamUsage.totalCost
}
updateApiReqMsg()
await this.saveClineMessages()
await this.providerRef.deref()?.postStateToWebview()
})
}
// need to call here in case the stream was aborted
if (this.abort) {
throw new Error("Cline instance aborted")
@@ -3381,8 +3236,6 @@ export class Cline {
// need to save assistant responses to file before proceeding to tool use since user can exit at any moment and we wouldn't be able to save the assistant's response
let didEndLoop = false
if (assistantMessage.length > 0) {
telemetryService.captureConversationTurnEvent(this.taskId, this.apiProvider, this.api.getModel().id, "assistant")
await this.addToApiConversationHistory({
role: "assistant",
content: [{ type: "text", text: assistantMessage }],
@@ -3625,7 +3478,7 @@ export class Cline {
details +=
"\nIn this mode you should focus on information gathering, asking questions, and architecting a solution. Once you have a plan, use the plan_mode_response tool to engage in a conversational back and forth with the user. Do not use the plan_mode_response tool until you've gathered all the information you need e.g. with read_file or ask_followup_question."
details +=
'\n(Remember: If it seems the user wants you to use tools only available in Act Mode, you should ask the user to "toggle to Act mode" (use those words) - they will have to manually do this themselves with the Plan/Act toggle button below. You do not have the ability to switch to Act Mode yourself, and must wait for the user to do it themselves once they are satisfied with the plan. You also cannot present an option to toggle to Act mode, as this will be something you need to direct the user to do manually themselves.)'
'\n(Remember: If it seems the user wants you to use tools only available in Act Mode, you should ask the user to "toggle to Act mode" (use those words) - they will have to manually do this themselves with the Plan/Act toggle button below. You do not have the ability to switch to Act Mode yourself, and must wait for the user to do it themselves once they are satisfied with the plan.)'
} else {
details += "\nACT MODE"
}
+61 -1
View File
@@ -45,7 +45,6 @@ export const toolParamNames = [
"arguments",
"uri",
"question",
"options",
"response",
"result",
] as const
@@ -59,3 +58,64 @@ export interface ToolUse {
params: Partial<Record<ToolParamName, string>>
partial: boolean
}
export interface ExecuteCommandToolUse extends ToolUse {
name: "execute_command"
// Pick<Record<ToolParamName, string>, "command"> makes "command" required, but Partial<> makes it optional
params: Partial<Pick<Record<ToolParamName, string>, "command" | "requires_approval">>
}
export interface ReadFileToolUse extends ToolUse {
name: "read_file"
params: Partial<Pick<Record<ToolParamName, string>, "path">>
}
export interface WriteToFileToolUse extends ToolUse {
name: "write_to_file"
params: Partial<Pick<Record<ToolParamName, string>, "path" | "content">>
}
export interface ReplaceInFileToolUse extends ToolUse {
name: "replace_in_file"
params: Partial<Pick<Record<ToolParamName, string>, "path" | "diff">>
}
export interface SearchFilesToolUse extends ToolUse {
name: "search_files"
params: Partial<Pick<Record<ToolParamName, string>, "path" | "regex" | "file_pattern">>
}
export interface ListFilesToolUse extends ToolUse {
name: "list_files"
params: Partial<Pick<Record<ToolParamName, string>, "path" | "recursive">>
}
export interface ListCodeDefinitionNamesToolUse extends ToolUse {
name: "list_code_definition_names"
params: Partial<Pick<Record<ToolParamName, string>, "path">>
}
export interface BrowserActionToolUse extends ToolUse {
name: "browser_action"
params: Partial<Pick<Record<ToolParamName, string>, "action" | "url" | "coordinate" | "text">>
}
export interface UseMcpToolToolUse extends ToolUse {
name: "use_mcp_tool"
params: Partial<Pick<Record<ToolParamName, string>, "server_name" | "tool_name" | "arguments">>
}
export interface AccessMcpResourceToolUse extends ToolUse {
name: "access_mcp_resource"
params: Partial<Pick<Record<ToolParamName, string>, "server_name" | "uri">>
}
export interface AskFollowupQuestionToolUse extends ToolUse {
name: "ask_followup_question"
params: Partial<Pick<Record<ToolParamName, string>, "question">>
}
export interface AttemptCompletionToolUse extends ToolUse {
name: "attempt_completion"
params: Partial<Pick<Record<ToolParamName, string>, "result" | "command">>
}
+5 -17
View File
@@ -216,13 +216,9 @@ Usage:
Description: Ask the user a question to gather additional information needed to complete the task. This tool should be used when you encounter ambiguities, need clarification, or require more details to proceed effectively. It allows for interactive problem-solving by enabling direct communication with the user. Use this tool judiciously to maintain a balance between gathering necessary information and avoiding excessive back-and-forth.
Parameters:
- question: (required) The question to ask the user. This should be a clear, specific question that addresses the information you need.
- options: (optional) An array of 2-5 options for the user to choose from. Each option should be a string describing a possible answer. You may not always need to provide options, but it may be helpful in many cases where it can save the user from having to type out a response manually.
Usage:
<ask_followup_question>
<question>Your question here</question>
<options>
Array of options here (optional), e.g. ["Option 1", "Option 2", "Option 3"]
</options>
</ask_followup_question>
## attempt_completion
@@ -243,13 +239,9 @@ Your final result description here
Description: Respond to the user's inquiry in an effort to plan a solution to the user's task. This tool should be used when you need to provide a response to a question or statement from the user about how you plan to accomplish the task. This tool is only available in PLAN MODE. The environment_details will specify the current mode, if it is not PLAN MODE then you should not use this tool. Depending on the user's message, you may ask questions to get clarification about the user's request, architect a solution to the task, and to brainstorm ideas with the user. For example, if the user's task is to create a website, you may start by asking some clarifying questions, then present a detailed plan for how you will accomplish the task given the context, and perhaps engage in a back and forth to finalize the details before the user switches you to ACT MODE to implement the solution.
Parameters:
- response: (required) The response to provide to the user. Do not try to use tools in this parameter, this is simply a chat response.
- options: (optional) An array of 2-5 options for the user to choose from. Each option should be a string describing a possible choice or path forward in the planning process. This can help guide the discussion and make it easier for the user to provide input on key decisions. You may not always need to provide options, but it may be helpful in many cases where it can save the user from having to type out a response manually. Do NOT present an option to toggle to Act mode, as this will be something you need to direct the user to do manually themselves.
Usage:
<plan_mode_response>
<response>Your response here</response>
<options>
Array of options here (optional), e.g. ["Option 1", "Option 2", "Option 3"]
</options>
</plan_mode_response>
# Tool Use Examples
@@ -775,7 +767,7 @@ IMPORTANT: Regardless of what else you see in the MCP settings file, you must de
(Note: the user may also ask you to install the MCP server to the Claude desktop app, in which case you would read then modify \`~/Library/Application\ Support/Claude/claude_desktop_config.json\` on macOS for example. It follows the same format of a top level \`mcpServers\` object.)
6. After you have edited the MCP settings configuration file, the system will automatically run all the servers and expose the available tools and resources in the 'Connected MCP Servers' section. (Note: If you encounter a 'not connected' error when testing a newly installed mcp server, a common cause is an incorrect build path in your MCP settings configuration. Since compiled JavaScript files are commonly output to either 'dist/' or 'build/' directories, double-check that the build path in your MCP settings matches where your files are actually being compiled. E.g. If you assumed 'build' as the folder, check tsconfig.json to see if it's using 'dist' instead.)
6. After you have edited the MCP settings configuration file, the system will automatically run all the servers and expose the available tools and resources in the 'Connected MCP Servers' section.
7. Now that you have access to these new tools and resources, you may suggest ways the user can command you to invoke them - for example, with this new weather tool now available, you can invite the user to ask "what's the weather in San Francisco?"
@@ -821,7 +813,7 @@ You have access to two tools for working with files: **write_to_file** and **rep
## Important Considerations
- Using write_to_file requires providing the file's complete final content.
- Using write_to_file requires providing the files complete final content.
- If you only need to make small changes to an existing file, consider using replace_in_file instead to avoid unnecessarily rewriting the entire file.
- While write_to_file should not be your default choice, don't hesitate to use it when the situation truly calls for it.
@@ -834,12 +826,12 @@ You have access to two tools for working with files: **write_to_file** and **rep
## When to Use
- Small, localized changes like updating a few lines, function implementations, changing variable names, modifying a section of text, etc.
- Targeted improvements where only specific portions of the file's content needs to be altered.
- Targeted improvements where only specific portions of the files content needs to be altered.
- Especially useful for long files where much of the file will remain unchanged.
## Advantages
- More efficient for minor edits, since you don't need to supply the entire file content.
- More efficient for minor edits, since you dont need to supply the entire file content.
- Reduces the chance of errors that can occur when overwriting large files.
# Choosing the Appropriate Tool
@@ -893,7 +885,7 @@ In each user message, the environment_details will specify the current mode. The
- When starting in PLAN MODE, depending on the user's request, you may need to do some information gathering e.g. using read_file or search_files to get more context about the task. You may also ask the user clarifying questions to get a better understanding of the task. You may return mermaid diagrams to visually display your understanding.
- Once you've gained more context about the user's request, you should architect a detailed plan for how you will accomplish the task. Returning mermaid diagrams may be helpful here as well.
- Then you might ask the user if they are pleased with this plan, or if they would like to make any changes. Think of this as a brainstorming session where you can discuss the task and plan the best way to accomplish it.
- If at any point a mermaid diagram would make your plan clearer to help the user quickly see the structure, you are encouraged to include a Mermaid code block in the response. (Note: if you use colors in your mermaid diagrams, be sure to use high contrast colors so the text is readable.)
- If at any point a mermaid diagram would make your plan clearer to help the user quickly see the structure, you are encouraged to include a Mermaid code block in the response.
- Finally once it seems like you've reached a good plan, ask the user to switch you back to ACT MODE to implement the solution.
====
@@ -987,12 +979,8 @@ export function addUserInstructions(
settingsCustomInstructions?: string,
clineRulesFileInstructions?: string,
clineIgnoreInstructions?: string,
preferredLanguageInstructions?: string,
) {
let customInstructions = ""
if (preferredLanguageInstructions) {
customInstructions += preferredLanguageInstructions + "\n\n"
}
if (settingsCustomInstructions) {
customInstructions += settingsCustomInstructions + "\n\n"
}
+247
View File
@@ -0,0 +1,247 @@
import * as vscode from "vscode"
import { SecretKey, GlobalStateKey } from "../../types/state"
import { ApiProvider, ModelInfo } from "../../shared/api"
import { HistoryItem } from "../../shared/HistoryItem"
import { AutoApprovalSettings, DEFAULT_AUTO_APPROVAL_SETTINGS } from "../../shared/AutoApprovalSettings"
import { BrowserSettings, DEFAULT_BROWSER_SETTINGS } from "../../shared/BrowserSettings"
import { ChatSettings, DEFAULT_CHAT_SETTINGS } from "../../shared/ChatSettings"
import { UserInfo } from "../../services/auth/FirebaseAuthManager"
export class StateManager {
constructor(private context: vscode.ExtensionContext) {}
async updateGlobalState(key: GlobalStateKey, value: any) {
await this.context.globalState.update(key, value)
}
async getGlobalState(key: GlobalStateKey) {
return await this.context.globalState.get(key)
}
async storeSecret(key: SecretKey, value?: string) {
if (value) {
await this.context.secrets.store(key, value)
} else {
await this.context.secrets.delete(key)
}
}
async getSecret(key: SecretKey) {
return await this.context.secrets.get(key)
}
async updateTaskHistory(item: HistoryItem): Promise<HistoryItem[]> {
const history = ((await this.getGlobalState("taskHistory")) as HistoryItem[]) || []
const existingItemIndex = history.findIndex((h) => h.id === item.id)
if (existingItemIndex !== -1) {
history[existingItemIndex] = item
} else {
history.push(item)
}
await this.updateGlobalState("taskHistory", history)
return history
}
async resetState() {
for (const key of this.context.globalState.keys()) {
await this.context.globalState.update(key, undefined)
}
const secretKeys: SecretKey[] = [
"apiKey",
"openRouterApiKey",
"awsAccessKey",
"awsSecretKey",
"awsSessionToken",
"openAiApiKey",
"geminiApiKey",
"openAiNativeApiKey",
"deepSeekApiKey",
"requestyApiKey",
"togetherApiKey",
"qwenApiKey",
"mistralApiKey",
"liteLlmApiKey",
"authToken",
]
for (const key of secretKeys) {
await this.storeSecret(key, undefined)
}
}
async getState() {
const [
storedApiProvider,
apiModelId,
apiKey,
openRouterApiKey,
awsAccessKey,
awsSecretKey,
awsSessionToken,
awsRegion,
awsUseCrossRegionInference,
awsProfile,
awsUseProfile,
vertexProjectId,
vertexRegion,
openAiBaseUrl,
openAiApiKey,
openAiModelId,
openAiModelInfo,
ollamaModelId,
ollamaBaseUrl,
lmStudioModelId,
lmStudioBaseUrl,
anthropicBaseUrl,
geminiApiKey,
openAiNativeApiKey,
deepSeekApiKey,
requestyApiKey,
requestyModelId,
togetherApiKey,
togetherModelId,
qwenApiKey,
mistralApiKey,
azureApiVersion,
openRouterModelId,
openRouterModelInfo,
lastShownAnnouncementId,
customInstructions,
taskHistory,
autoApprovalSettings,
browserSettings,
chatSettings,
vsCodeLmModelSelector,
liteLlmBaseUrl,
liteLlmModelId,
userInfo,
authToken,
previousModeApiProvider,
previousModeModelId,
previousModeModelInfo,
qwenApiLine,
liteLlmApiKey,
] = await Promise.all([
this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>,
this.getGlobalState("apiModelId"),
this.getSecret("apiKey"),
this.getSecret("openRouterApiKey"),
this.getSecret("awsAccessKey"),
this.getSecret("awsSecretKey"),
this.getSecret("awsSessionToken"),
this.getGlobalState("awsRegion"),
this.getGlobalState("awsUseCrossRegionInference"),
this.getGlobalState("awsProfile"),
this.getGlobalState("awsUseProfile"),
this.getGlobalState("vertexProjectId"),
this.getGlobalState("vertexRegion"),
this.getGlobalState("openAiBaseUrl"),
this.getSecret("openAiApiKey"),
this.getGlobalState("openAiModelId"),
this.getGlobalState("openAiModelInfo"),
this.getGlobalState("ollamaModelId"),
this.getGlobalState("ollamaBaseUrl"),
this.getGlobalState("lmStudioModelId"),
this.getGlobalState("lmStudioBaseUrl"),
this.getGlobalState("anthropicBaseUrl"),
this.getSecret("geminiApiKey"),
this.getSecret("openAiNativeApiKey"),
this.getSecret("deepSeekApiKey"),
this.getSecret("requestyApiKey"),
this.getGlobalState("requestyModelId"),
this.getSecret("togetherApiKey"),
this.getGlobalState("togetherModelId"),
this.getSecret("qwenApiKey"),
this.getSecret("mistralApiKey"),
this.getGlobalState("azureApiVersion"),
this.getGlobalState("openRouterModelId"),
this.getGlobalState("openRouterModelInfo"),
this.getGlobalState("lastShownAnnouncementId"),
this.getGlobalState("customInstructions"),
this.getGlobalState("taskHistory"),
this.getGlobalState("autoApprovalSettings"),
this.getGlobalState("browserSettings"),
this.getGlobalState("chatSettings"),
this.getGlobalState("vsCodeLmModelSelector"),
this.getGlobalState("liteLlmBaseUrl"),
this.getGlobalState("liteLlmModelId"),
this.getGlobalState("userInfo"),
this.getSecret("authToken"),
this.getGlobalState("previousModeApiProvider"),
this.getGlobalState("previousModeModelId"),
this.getGlobalState("previousModeModelInfo"),
this.getGlobalState("qwenApiLine"),
this.getSecret("liteLlmApiKey"),
])
let apiProvider: ApiProvider
if (storedApiProvider) {
apiProvider = storedApiProvider
} else {
if (apiKey) {
apiProvider = "anthropic"
} else {
apiProvider = "openrouter"
}
}
const o3MiniReasoningEffort = vscode.workspace
.getConfiguration("cline.modelSettings.o3Mini")
.get("reasoningEffort", "medium")
return {
apiConfiguration: {
apiProvider,
apiModelId,
apiKey,
openRouterApiKey,
awsAccessKey,
awsSecretKey,
awsSessionToken,
awsRegion,
awsUseCrossRegionInference,
awsProfile,
awsUseProfile,
vertexProjectId,
vertexRegion,
openAiBaseUrl,
openAiApiKey,
openAiModelId,
openAiModelInfo,
ollamaModelId,
ollamaBaseUrl,
lmStudioModelId,
lmStudioBaseUrl,
anthropicBaseUrl,
geminiApiKey,
openAiNativeApiKey,
deepSeekApiKey,
requestyApiKey,
requestyModelId,
togetherApiKey,
togetherModelId,
qwenApiKey,
qwenApiLine,
mistralApiKey,
azureApiVersion,
openRouterModelId,
openRouterModelInfo,
vsCodeLmModelSelector,
o3MiniReasoningEffort,
liteLlmBaseUrl,
liteLlmModelId,
liteLlmApiKey,
},
lastShownAnnouncementId,
customInstructions,
taskHistory,
autoApprovalSettings: autoApprovalSettings || DEFAULT_AUTO_APPROVAL_SETTINGS,
browserSettings: browserSettings || DEFAULT_BROWSER_SETTINGS,
chatSettings: chatSettings || DEFAULT_CHAT_SETTINGS,
userInfo,
authToken,
previousModeApiProvider,
previousModeModelId,
previousModeModelInfo,
}
}
}
File diff suppressed because it is too large Load Diff
+105
View File
@@ -0,0 +1,105 @@
import * as vscode from "vscode"
import { Cline } from "../Cline"
import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker"
import { McpHub } from "../../services/mcp/McpHub"
import { FirebaseAuthManager } from "../../services/auth/FirebaseAuthManager"
import { StateManager } from "../state/StateManager"
import { WebviewMessageHandler } from "./WebviewMessageHandler"
import { IClineProvider } from "./IClineProvider"
import { ExtensionMessage, ExtensionState } from "../../shared/ExtensionMessage"
import { GlobalStateKey, SecretKey } from "../../types/state"
import { HistoryItem } from "../../shared/HistoryItem"
import { ApiConfiguration } from "../../api/types"
export abstract class ClineProviderBase implements vscode.WebviewViewProvider, IClineProvider {
public static readonly sideBarId = "claude-dev.SidebarProvider"
public static readonly tabPanelId = "claude-dev.TabPanelProvider"
private static activeInstances: Set<IClineProvider> = new Set()
protected disposables: vscode.Disposable[] = []
protected view?: vscode.WebviewView | vscode.WebviewPanel
protected cline?: Cline
workspaceTracker?: WorkspaceTracker
mcpHub?: McpHub
protected stateManager: StateManager
protected messageHandler: WebviewMessageHandler
protected authManager: FirebaseAuthManager
readonly latestAnnouncementId = "jan-20-2025"
constructor(
readonly context: vscode.ExtensionContext,
protected readonly outputChannel: vscode.OutputChannel,
) {
this.outputChannel.appendLine("ClineProvider instantiated")
ClineProviderBase.activeInstances.add(this)
this.stateManager = new StateManager(context)
this.messageHandler = new WebviewMessageHandler(this, this.stateManager)
// Initialize these after messageHandler since they depend on IClineProvider
this.workspaceTracker = new WorkspaceTracker(this)
this.mcpHub = new McpHub(this)
this.authManager = new FirebaseAuthManager(this)
}
// Methods that need to be accessible to WebviewMessageHandler
getCline(): Cline | undefined {
return this.cline
}
setCline(cline: Cline | undefined) {
this.cline = cline
}
getLatestAnnouncementId(): string {
return this.latestAnnouncementId
}
// Required abstract methods that must be implemented by ClineProvider
abstract resolveWebviewView(webviewView: vscode.WebviewView | vscode.WebviewPanel): void | Thenable<void>
abstract dispose(): Promise<void>
abstract handleSignOut(): Promise<void>
abstract setAuthToken(token?: string): Promise<void>
abstract setUserInfo(info?: { displayName: string | null; email: string | null; photoURL: string | null }): Promise<void>
abstract postMessageToWebview(message: ExtensionMessage): Promise<void>
abstract postStateToWebview(): Promise<void>
abstract getState(): Promise<{
apiConfiguration: ApiConfiguration
lastShownAnnouncementId?: string
customInstructions?: string
taskHistory?: HistoryItem[]
autoApprovalSettings: any
browserSettings: any
chatSettings: any
userInfo?: any
authToken?: string
}>
abstract updateGlobalState(key: GlobalStateKey, value: any): Promise<void>
abstract getGlobalState(key: GlobalStateKey): Promise<any>
abstract storeSecret(key: SecretKey, value?: string): Promise<void>
abstract getSecret(key: SecretKey): Promise<any>
// Required method for task management
abstract clearTask(): Promise<void>
// Additional required abstract methods
abstract initClineWithTask(task?: string, images?: string[]): Promise<void>
abstract initClineWithHistoryItem(historyItem: HistoryItem): Promise<void>
abstract updateCustomInstructions(instructions?: string): Promise<void>
abstract cancelTask(): Promise<void>
abstract getTaskWithId(id: string): Promise<{
historyItem: HistoryItem
taskDirPath: string
apiConversationHistoryFilePath: string
uiMessagesFilePath: string
apiConversationHistory: any[]
}>
abstract deleteTaskWithId(id: string): Promise<void>
// Protected abstract methods that ClineProvider must implement
protected abstract fileExists(path: string): Promise<boolean>
protected abstract deleteTaskFromState(id: string): Promise<void>
// Public abstract methods that ClineProvider must implement
abstract getStateToWebview(): Promise<ExtensionState>
protected abstract getHtmlContent(webview: vscode.Webview): string
}
+51
View File
@@ -0,0 +1,51 @@
import * as vscode from "vscode"
import { Cline } from "../Cline"
import { GlobalStateKey, SecretKey } from "../../types/state"
import { HistoryItem } from "../../shared/HistoryItem"
import { ExtensionMessage } from "../../shared/ExtensionMessage"
import { ApiConfiguration } from "../../shared/api"
import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker"
export interface IClineProvider {
workspaceTracker?: WorkspaceTracker
readonly context: vscode.ExtensionContext
getCline(): Cline | undefined
setCline(cline: Cline | undefined): void
getLatestAnnouncementId(): string
dispose(): Promise<void>
handleSignOut(): Promise<void>
setAuthToken(token?: string): Promise<void>
setUserInfo(info?: { displayName: string | null; email: string | null; photoURL: string | null }): Promise<void>
postMessageToWebview(message: ExtensionMessage): Promise<void>
postStateToWebview(): Promise<void>
getState(): Promise<{
apiConfiguration: ApiConfiguration
lastShownAnnouncementId?: string
customInstructions?: string
taskHistory?: HistoryItem[]
autoApprovalSettings: any
browserSettings: any
chatSettings: any
userInfo?: any
authToken?: string
}>
updateGlobalState(key: GlobalStateKey, value: any): Promise<void>
getGlobalState(key: GlobalStateKey): Promise<any>
storeSecret(key: SecretKey, value?: string): Promise<void>
getSecret(key: SecretKey): Promise<any>
// Additional required methods
clearTask(): Promise<void>
initClineWithTask(task?: string, images?: string[]): Promise<void>
initClineWithHistoryItem(historyItem: HistoryItem): Promise<void>
updateCustomInstructions(instructions?: string): Promise<void>
cancelTask(): Promise<void>
getTaskWithId(id: string): Promise<{
historyItem: HistoryItem
taskDirPath: string
apiConversationHistoryFilePath: string
uiMessagesFilePath: string
apiConversationHistory: any[]
}>
deleteTaskWithId(id: string): Promise<void>
}
+365
View File
@@ -0,0 +1,365 @@
import * as vscode from "vscode"
import { WebviewMessage, ClineCheckpointRestore } from "../../shared/WebviewMessage"
import { StateManager } from "../state/StateManager"
import { IClineProvider } from "./IClineProvider"
import { buildApiHandler } from "../../api"
import { selectImages } from "../../integrations/misc/process-images"
import { openFile, openImage } from "../../integrations/misc/open-file"
import { openMention } from "../mentions"
import { downloadTask } from "../../integrations/misc/export-markdown"
import { searchCommits } from "../../utils/git"
import { getTheme } from "../../integrations/theme/getTheme"
import pWaitFor from "p-wait-for"
import crypto from "crypto"
import { ApiConfiguration } from "../../shared/api"
import { ExtensionMessage } from "../../shared/ExtensionMessage"
export class WebviewMessageHandler {
constructor(
private provider: IClineProvider,
private stateManager: StateManager,
) {}
async handleMessage(message: WebviewMessage) {
switch (message.type) {
case "webviewDidLaunch":
await this.handleWebviewLaunch()
break
case "newTask":
await this.handleNewTask(message)
break
case "apiConfiguration":
await this.handleApiConfiguration(message)
break
case "customInstructions":
await this.handleCustomInstructions(message)
break
case "autoApprovalSettings":
await this.handleAutoApprovalSettings(message)
break
case "browserSettings":
await this.handleBrowserSettings(message)
break
case "chatSettings":
await this.handleChatSettings(message)
break
case "askResponse":
await this.handleAskResponse(message)
break
case "clearTask":
await this.handleClearTask()
break
case "didShowAnnouncement":
await this.handleDidShowAnnouncement()
break
case "selectImages":
await this.handleSelectImages()
break
case "exportCurrentTask":
await this.handleExportCurrentTask()
break
case "showTaskWithId":
await this.handleShowTaskWithId(message)
break
case "deleteTaskWithId":
await this.handleDeleteTaskWithId(message)
break
case "exportTaskWithId":
await this.handleExportTaskWithId(message)
break
case "resetState":
await this.handleResetState()
break
case "openImage":
openImage(message.text!)
break
case "openFile":
openFile(message.text!)
break
case "openMention":
openMention(message.text)
break
case "checkpointDiff":
await this.handleCheckpointDiff(message)
break
case "checkpointRestore":
await this.handleCheckpointRestore(message)
break
case "taskCompletionViewChanges":
await this.handleTaskCompletionViewChanges(message)
break
case "cancelTask":
await this.provider.cancelTask()
break
case "getLatestState":
await this.provider.postStateToWebview()
break
case "accountLoginClicked":
await this.handleAccountLoginClicked()
break
case "accountLogoutClicked":
await this.provider.handleSignOut()
break
case "searchCommits":
await this.handleSearchCommits(message)
break
case "openExtensionSettings":
await this.handleOpenExtensionSettings(message)
break
}
}
private async handleWebviewLaunch() {
await this.provider.postStateToWebview()
this.provider.workspaceTracker?.populateFilePaths()
const theme = await getTheme()
await this.provider.postMessageToWebview({
type: "theme",
text: JSON.stringify(theme),
})
}
private async handleNewTask(message: WebviewMessage) {
await this.provider.initClineWithTask(message.text, message.images)
}
private async handleApiConfiguration(message: WebviewMessage) {
if (message.apiConfiguration) {
const config = message.apiConfiguration as ApiConfiguration
await this.stateManager.updateGlobalState("apiProvider", config.apiProvider)
await this.stateManager.updateGlobalState("apiModelId", config.apiModelId)
await this.stateManager.storeSecret("apiKey", config.apiKey)
await this.stateManager.storeSecret("openRouterApiKey", config.openRouterApiKey)
await this.stateManager.storeSecret("awsAccessKey", config.awsAccessKey)
await this.stateManager.storeSecret("awsSecretKey", config.awsSecretKey)
await this.stateManager.storeSecret("awsSessionToken", config.awsSessionToken)
await this.stateManager.updateGlobalState("awsRegion", config.awsRegion)
await this.stateManager.updateGlobalState("awsUseCrossRegionInference", config.awsUseCrossRegionInference)
await this.stateManager.updateGlobalState("awsProfile", config.awsProfile)
await this.stateManager.updateGlobalState("awsUseProfile", config.awsUseProfile)
await this.stateManager.updateGlobalState("vertexProjectId", config.vertexProjectId)
await this.stateManager.updateGlobalState("vertexRegion", config.vertexRegion)
await this.stateManager.updateGlobalState("openAiBaseUrl", config.openAiBaseUrl)
await this.stateManager.storeSecret("openAiApiKey", config.openAiApiKey)
await this.stateManager.updateGlobalState("openAiModelId", config.openAiModelId)
await this.stateManager.updateGlobalState("openAiModelInfo", config.openAiModelInfo)
await this.stateManager.updateGlobalState("ollamaModelId", config.ollamaModelId)
await this.stateManager.updateGlobalState("ollamaBaseUrl", config.ollamaBaseUrl)
await this.stateManager.updateGlobalState("lmStudioModelId", config.lmStudioModelId)
await this.stateManager.updateGlobalState("lmStudioBaseUrl", config.lmStudioBaseUrl)
await this.stateManager.updateGlobalState("anthropicBaseUrl", config.anthropicBaseUrl)
await this.stateManager.storeSecret("geminiApiKey", config.geminiApiKey)
await this.stateManager.storeSecret("openAiNativeApiKey", config.openAiNativeApiKey)
await this.stateManager.storeSecret("deepSeekApiKey", config.deepSeekApiKey)
await this.stateManager.storeSecret("requestyApiKey", config.requestyApiKey)
await this.stateManager.storeSecret("togetherApiKey", config.togetherApiKey)
await this.stateManager.storeSecret("qwenApiKey", config.qwenApiKey)
await this.stateManager.storeSecret("mistralApiKey", config.mistralApiKey)
await this.stateManager.updateGlobalState("azureApiVersion", config.azureApiVersion)
await this.stateManager.updateGlobalState("openRouterModelId", config.openRouterModelId)
await this.stateManager.updateGlobalState("openRouterModelInfo", config.openRouterModelInfo)
await this.stateManager.updateGlobalState("vsCodeLmModelSelector", config.vsCodeLmModelSelector)
await this.stateManager.updateGlobalState("liteLlmBaseUrl", config.liteLlmBaseUrl)
await this.stateManager.updateGlobalState("liteLlmModelId", config.liteLlmModelId)
await this.stateManager.storeSecret("liteLlmApiKey", config.liteLlmApiKey)
await this.stateManager.updateGlobalState("qwenApiLine", config.qwenApiLine)
await this.stateManager.updateGlobalState("requestyModelId", config.requestyModelId)
await this.stateManager.updateGlobalState("togetherModelId", config.togetherModelId)
if (this.provider.getCline()) {
this.provider.getCline()!.api = buildApiHandler(message.apiConfiguration)
}
}
await this.provider.postStateToWebview()
}
private async handleCustomInstructions(message: WebviewMessage) {
await this.provider.updateCustomInstructions(message.text)
}
private async handleAutoApprovalSettings(message: WebviewMessage) {
if (message.autoApprovalSettings) {
await this.stateManager.updateGlobalState("autoApprovalSettings", message.autoApprovalSettings)
const cline = this.provider.getCline()
if (cline) {
cline.autoApprovalSettings = message.autoApprovalSettings
}
await this.provider.postStateToWebview()
}
}
private async handleBrowserSettings(message: WebviewMessage) {
if (message.browserSettings) {
await this.stateManager.updateGlobalState("browserSettings", message.browserSettings)
const cline = this.provider.getCline()
if (cline) {
cline.updateBrowserSettings(message.browserSettings)
}
await this.provider.postStateToWebview()
}
}
private async handleChatSettings(message: WebviewMessage) {
if (message.chatSettings) {
const didSwitchToActMode = message.chatSettings.mode === "act"
await this.stateManager.updateGlobalState("chatSettings", message.chatSettings)
const cline = this.provider.getCline()
if (cline) {
cline.updateChatSettings(message.chatSettings)
if (cline.isAwaitingPlanResponse && didSwitchToActMode) {
cline.didRespondToPlanAskBySwitchingMode = true
await this.provider.postMessageToWebview({
type: "invoke",
invoke: "sendMessage",
text: message.chatContent?.message || "PLAN_MODE_TOGGLE_RESPONSE",
images: message.chatContent?.images,
})
} else {
this.provider.cancelTask()
}
}
await this.provider.postStateToWebview()
}
}
private async handleAskResponse(message: WebviewMessage) {
const cline = this.provider.getCline()
if (cline) {
cline.handleWebviewAskResponse(message.askResponse!, message.text, message.images)
}
}
private async handleClearTask() {
await this.provider.clearTask()
await this.provider.postStateToWebview()
}
private async handleDidShowAnnouncement() {
await this.stateManager.updateGlobalState("lastShownAnnouncementId", this.provider.getLatestAnnouncementId())
await this.provider.postStateToWebview()
}
private async handleSelectImages() {
const images = await selectImages()
await this.provider.postMessageToWebview({
type: "selectedImages",
images,
})
}
private async handleExportCurrentTask() {
const currentTaskId = this.provider.getCline()?.taskId
if (currentTaskId) {
await this.handleExportTaskWithId({ type: "exportTaskWithId", text: currentTaskId })
}
}
private async handleShowTaskWithId(message: WebviewMessage) {
const cline = this.provider.getCline()
if (message.text !== cline?.taskId) {
const { historyItem } = await this.provider.getTaskWithId(message.text!)
await this.provider.initClineWithHistoryItem(historyItem)
}
await this.provider.postMessageToWebview({
type: "action",
action: "chatButtonClicked",
})
}
private async handleDeleteTaskWithId(message: WebviewMessage) {
await this.provider.deleteTaskWithId(message.text!)
}
private async handleExportTaskWithId(message: WebviewMessage) {
const { historyItem, apiConversationHistory } = await this.provider.getTaskWithId(message.text!)
await downloadTask(historyItem.ts, apiConversationHistory)
}
private async handleResetState() {
await this.stateManager.resetState()
const cline = this.provider.getCline()
if (cline) {
cline.abortTask()
this.provider.setCline(undefined)
}
await this.provider.postStateToWebview()
await this.provider.postMessageToWebview({
type: "action",
action: "chatButtonClicked",
})
}
private async handleCheckpointDiff(message: WebviewMessage) {
if (message.number) {
const cline = this.provider.getCline()
if (cline) {
await cline.presentMultifileDiff(message.number, false)
}
}
}
private async handleCheckpointRestore(message: WebviewMessage) {
await this.provider.cancelTask()
if (message.number) {
const cline = this.provider.getCline()
await pWaitFor(() => cline?.isInitialized === true, {
timeout: 3_000,
}).catch(() => {
console.error("Failed to init new cline instance")
})
if (cline) {
const restore: ClineCheckpointRestore = {
checkpointNumber: message.number,
restoreMode: (message.text || "task") as "task" | "workspace" | "taskAndWorkspace",
}
await cline.restoreCheckpoint(message.number, restore)
}
}
}
private async handleTaskCompletionViewChanges(message: WebviewMessage) {
if (message.number) {
const cline = this.provider.getCline()
if (cline) {
await cline.presentMultifileDiff(message.number, true)
}
}
}
private async handleAccountLoginClicked() {
const nonce = crypto.randomBytes(32).toString("hex")
await this.stateManager.storeSecret("authNonce", nonce)
const uriScheme = vscode.env.uriScheme
const authUrl = vscode.Uri.parse(
`https://app.cline.bot/auth?state=${encodeURIComponent(nonce)}&callback_url=${encodeURIComponent(
`${uriScheme || "vscode"}://saoudrizwan.claude-dev/auth`,
)}`,
)
vscode.env.openExternal(authUrl)
}
private async handleSearchCommits(message: WebviewMessage) {
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
if (cwd) {
try {
const commits = await searchCommits(message.text || "", cwd)
await this.provider.postMessageToWebview({
type: "commitSearchResults",
commits,
})
} catch (error) {
console.error(`Error searching commits: ${JSON.stringify(error)}`)
}
}
}
private async handleOpenExtensionSettings(message: WebviewMessage) {
const settingsFilter = message.text || ""
await vscode.commands.executeCommand(
"workbench.action.openSettings",
`@ext:saoudrizwan.claude-dev ${settingsFilter}`.trim(),
)
}
}
+2 -26
View File
@@ -7,8 +7,6 @@ import { Logger } from "./services/logging/Logger"
import { createClineAPI } from "./exports"
import "./utils/path" // necessary to have access to String.prototype.toPosix
import { DIFF_VIEW_URI_SCHEME } from "./integrations/editor/DiffViewProvider"
import assert from "node:assert"
import { telemetryService } from "./services/telemetry/TelemetryService"
/*
Built using https://github.com/microsoft/vscode-webview-ui-toolkit
@@ -162,12 +160,10 @@ export function activate(context: vscode.ExtensionContext) {
case "/auth": {
const token = query.get("token")
const state = query.get("state")
const apiKey = query.get("apiKey")
console.log("Auth callback received:", {
token: token,
state: state,
apiKey: apiKey,
})
// Validate state parameter
@@ -176,8 +172,8 @@ export function activate(context: vscode.ExtensionContext) {
return
}
if (token && apiKey) {
await visibleProvider.handleAuthCallback(token, apiKey)
if (token) {
await visibleProvider.handleAuthCallback(token)
}
break
}
@@ -192,25 +188,5 @@ export function activate(context: vscode.ExtensionContext) {
// This method is called when your extension is deactivated
export function deactivate() {
telemetryService.shutdown()
Logger.log("Cline extension deactivated")
}
// TODO: Find a solution for automatically removing DEV related content from production builds.
// This type of code is fine in production to keep. We just will want to remove it from production builds
// to bring down built asset sizes.
//
// This is a workaround to reload the extension when the source code changes
// since vscode doesn't support hot reload for extensions
const { IS_DEV, DEV_WORKSPACE_FOLDER } = process.env
if (IS_DEV && IS_DEV !== "false") {
assert(DEV_WORKSPACE_FOLDER, "DEV_WORKSPACE_FOLDER must be set in development")
const watcher = vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(DEV_WORKSPACE_FOLDER, "src/**/*"))
watcher.onDidChange(({ scheme, path }) => {
console.info(`${scheme} ${path} changed. Reloading VSCode...`)
vscode.commands.executeCommand("workbench.action.reloadWindow")
})
}
@@ -1,73 +0,0 @@
import * as vscode from "vscode"
import fs from "fs/promises"
import path from "path"
import os from "os"
import CheckpointTracker from "./CheckpointTracker"
export async function createTestEnvironment() {
// Create temp directory structure
const tempDir = path.join(os.tmpdir(), `checkpoint-test-${Date.now()}`)
await fs.mkdir(tempDir, { recursive: true })
// Create storage path outside of working directory to avoid submodule issues
const globalStoragePath = path.join(os.tmpdir(), `storage-${Date.now()}`)
await fs.mkdir(globalStoragePath, { recursive: true })
// Create test file in a subdirectory
const testDir = path.join(tempDir, "src")
await fs.mkdir(testDir, { recursive: true })
const testFilePath = path.join(testDir, "test.txt")
// Create .gitignore to prevent git from treating directories as submodules
await fs.writeFile(path.join(tempDir, ".gitignore"), "storage/\n")
// Mock VS Code workspace
const mockWorkspaceFolders = [
{
uri: { fsPath: tempDir },
name: "test",
index: 0,
},
]
const originalDescriptor = Object.getOwnPropertyDescriptor(vscode.workspace, "workspaceFolders")
Object.defineProperty(vscode.workspace, "workspaceFolders", {
get: () => mockWorkspaceFolders,
})
// Mock findFiles to return no nested git repos
const originalFindFiles = vscode.workspace.findFiles
vscode.workspace.findFiles = async () => []
// Mock VS Code configuration
const originalGetConfiguration = vscode.workspace.getConfiguration
vscode.workspace.getConfiguration = () =>
({
get: (key: string) => (key === "enableCheckpoints" ? true : undefined),
}) as any
return {
tempDir,
globalStoragePath,
testFilePath,
originalDescriptor,
originalFindFiles,
originalGetConfiguration,
cleanup: async () => {
// Restore VS Code mocks
if (originalDescriptor) {
Object.defineProperty(vscode.workspace, "workspaceFolders", originalDescriptor)
}
vscode.workspace.getConfiguration = originalGetConfiguration
vscode.workspace.findFiles = originalFindFiles
// Clean up temp directories
await fs.rm(tempDir, { recursive: true, force: true })
await fs.rm(globalStoragePath, { recursive: true, force: true })
}
}
}
export async function createTestTracker(globalStoragePath?: string, taskId = "test-task-1") {
return await CheckpointTracker.create(taskId, globalStoragePath)
}
@@ -1,155 +0,0 @@
import { expect } from "chai"
import { describe, it, beforeEach, afterEach } from "mocha"
import fs from "fs/promises"
import path from "path"
import { createTestEnvironment, createTestTracker } from "./Checkpoint-test-utils"
describe("Checkpoint Commit Operations", () => {
let env: Awaited<ReturnType<typeof createTestEnvironment>>
beforeEach(async () => {
env = await createTestEnvironment()
})
afterEach(async () => {
await env.cleanup()
})
it("should create commit with single file changes", async () => {
const tracker = await createTestTracker(env.globalStoragePath)
if (!tracker) {throw new Error("Failed to create tracker")}
// Create initial file
await fs.writeFile(env.testFilePath, "initial content")
// Create first commit
const firstCommit = await tracker.commit()
expect(firstCommit).to.be.a("string").and.not.empty
// Modify file
await fs.writeFile(env.testFilePath, "modified content")
// Create second commit
const secondCommit = await tracker.commit()
expect(secondCommit).to.be.a("string").and.not.empty
expect(secondCommit).to.not.equal(firstCommit)
// Verify commits are different
const diffSet = await tracker.getDiffSet(firstCommit, secondCommit)
expect(diffSet).to.have.lengthOf(1)
expect(diffSet[0].before).to.equal("initial content")
expect(diffSet[0].after).to.equal("modified content")
})
it("should create commit with multiple file changes", async () => {
const tracker = await createTestTracker(env.globalStoragePath)
if (!tracker) {throw new Error("Failed to create tracker")}
// Create initial files with newlines
const testFile2Path = path.join(env.tempDir, "src", "test2.txt")
await fs.writeFile(env.testFilePath, "file1 initial\n")
await fs.writeFile(testFile2Path, "file2 initial\n")
// Create first commit
const firstCommit = await tracker.commit()
expect(firstCommit).to.be.a("string").and.not.empty
// Modify both files with newlines
await fs.writeFile(env.testFilePath, "file1 modified\n")
await fs.writeFile(testFile2Path, "file2 modified\n")
// Create second commit
const secondCommit = await tracker.commit()
expect(secondCommit).to.be.a("string").and.not.empty
expect(secondCommit).to.not.equal(firstCommit)
// Get diff between commits
const diffSet = await tracker.getDiffSet(firstCommit, secondCommit)
expect(diffSet).to.have.lengthOf(2)
// Sort diffSet by path for consistent ordering
const sortedDiffs = diffSet.sort((a, b) => a.relativePath.localeCompare(b.relativePath))
// Verify file paths
expect(sortedDiffs[0].relativePath).to.equal("src/test.txt")
expect(sortedDiffs[1].relativePath).to.equal("src/test2.txt")
// Verify file contents
expect(sortedDiffs[0].before).to.equal("file1 initial\n")
expect(sortedDiffs[0].after).to.equal("file1 modified\n")
expect(sortedDiffs[1].before).to.equal("file2 initial\n")
expect(sortedDiffs[1].after).to.equal("file2 modified\n")
})
it("should create commit when files are deleted", async () => {
const tracker = await createTestTracker(env.globalStoragePath)
if (!tracker) {throw new Error("Failed to create tracker")}
// Create and commit initial file
await fs.writeFile(env.testFilePath, "initial content")
const firstCommit = await tracker.commit()
expect(firstCommit).to.be.a("string").and.not.empty
// Delete file
await fs.unlink(env.testFilePath)
// Create second commit
const secondCommit = await tracker.commit()
expect(secondCommit).to.be.a("string").and.not.empty
expect(secondCommit).to.not.equal(firstCommit)
// Verify file deletion was committed
const diffSet = await tracker.getDiffSet(firstCommit, secondCommit)
expect(diffSet).to.have.lengthOf(1)
expect(diffSet[0].before).to.equal("initial content")
expect(diffSet[0].after).to.equal("")
})
it("should create empty commit when no changes", async () => {
const tracker = await createTestTracker(env.globalStoragePath)
if (!tracker) {throw new Error("Failed to create tracker")}
// Create and commit initial file
await fs.writeFile(env.testFilePath, "test content")
const firstCommit = await tracker.commit()
expect(firstCommit).to.be.a("string").and.not.empty
// Create commit without changes
const secondCommit = await tracker.commit()
expect(secondCommit).to.be.a("string").and.not.empty
expect(secondCommit).to.not.equal(firstCommit)
// Verify no changes between commits
const diffSet = await tracker.getDiffSet(firstCommit, secondCommit)
expect(diffSet).to.have.lengthOf(0)
})
it("should handle files in nested directories", async () => {
const tracker = await createTestTracker(env.globalStoragePath)
if (!tracker) {throw new Error("Failed to create tracker")}
// Create nested directory structure
const nestedDir = path.join(env.tempDir, "src", "deep", "nested")
await fs.mkdir(nestedDir, { recursive: true })
const nestedFilePath = path.join(nestedDir, "nested.txt")
// Create and commit file in nested directory
await fs.writeFile(nestedFilePath, "nested content")
const firstCommit = await tracker.commit()
expect(firstCommit).to.be.a("string").and.not.empty
// Modify nested file
await fs.writeFile(nestedFilePath, "modified nested content")
// Create second commit
const secondCommit = await tracker.commit()
expect(secondCommit).to.be.a("string").and.not.empty
// Verify changes were committed
const diffSet = await tracker.getDiffSet(firstCommit, secondCommit)
expect(diffSet).to.have.lengthOf(1)
expect(diffSet[0].relativePath).to.equal("src/deep/nested/nested.txt")
expect(diffSet[0].before).to.equal("nested content")
expect(diffSet[0].after).to.equal("modified nested content")
})
})
@@ -1,35 +0,0 @@
import { expect } from "chai"
import { describe, it, beforeEach, afterEach } from "mocha"
import { createTestEnvironment, createTestTracker } from "./Checkpoint-test-utils"
import CheckpointTracker from "./CheckpointTracker"
describe("Checkpoint Creation", () => {
let env: Awaited<ReturnType<typeof createTestEnvironment>>
beforeEach(async () => {
env = await createTestEnvironment()
})
afterEach(async () => {
await env.cleanup()
})
it("should create a new checkpoint tracker", async () => {
const tracker = await createTestTracker(env.globalStoragePath)
expect(tracker).to.not.be.undefined
expect(tracker).to.be.instanceOf(CheckpointTracker)
// Verify shadow git config
const configWorkTree = await tracker?.getShadowGitConfigWorkTree()
expect(configWorkTree).to.not.be.undefined
})
it("should throw error when globalStoragePath is missing", async () => {
try {
await createTestTracker(undefined)
expect.fail("Expected error was not thrown")
} catch (error: any) {
expect(error.message).to.equal("Global storage path is required to create a checkpoint tracker")
}
})
})
@@ -1,68 +0,0 @@
import { expect } from "chai"
import { describe, it, beforeEach, afterEach } from "mocha"
import fs from "fs/promises"
import { createTestEnvironment, createTestTracker } from "./Checkpoint-test-utils"
describe("Checkpoint Diff Operations", () => {
let env: Awaited<ReturnType<typeof createTestEnvironment>>
beforeEach(async () => {
env = await createTestEnvironment()
})
afterEach(async () => {
await env.cleanup()
})
it("should detect file changes between commits", async () => {
const tracker = await createTestTracker(env.globalStoragePath)
if (!tracker) {throw new Error("Failed to create tracker")}
// Create initial file
await fs.writeFile(env.testFilePath, "initial content")
// Create first checkpoint
const firstCommit = await tracker.commit()
expect(firstCommit).to.not.be.undefined
// Modify file
await fs.writeFile(env.testFilePath, "modified content")
// Create second checkpoint
const secondCommit = await tracker.commit()
expect(secondCommit).to.not.be.undefined
// Get diff between commits
const diffSet = await tracker.getDiffSet(firstCommit, secondCommit)
// Verify diff results
expect(diffSet).to.have.lengthOf(1)
expect(diffSet[0].relativePath).to.equal("src/test.txt")
expect(diffSet[0].before).to.equal("initial content")
expect(diffSet[0].after).to.equal("modified content")
})
it("should detect changes between commit and working directory", async () => {
const tracker = await createTestTracker(env.globalStoragePath)
if (!tracker) {throw new Error("Failed to create tracker")}
// Create initial file
await fs.writeFile(env.testFilePath, "initial content")
// Create checkpoint
const commit = await tracker.commit()
expect(commit).to.not.be.undefined
// Modify file without committing
await fs.writeFile(env.testFilePath, "working directory changes")
// Get diff between commit and working directory
const diffSet = await tracker.getDiffSet(commit)
// Verify diff results
expect(diffSet).to.have.lengthOf(1)
expect(diffSet[0].relativePath).to.equal("src/test.txt")
expect(diffSet[0].before).to.equal("initial content")
expect(diffSet[0].after).to.equal("working directory changes")
})
})
@@ -1,94 +0,0 @@
import { expect } from "chai"
import { describe, it, beforeEach, afterEach } from "mocha"
import fs from "fs/promises"
import * as vscode from "vscode"
import { createTestEnvironment, createTestTracker } from "./Checkpoint-test-utils"
describe("Checkpoint Disabled State", () => {
let env: Awaited<ReturnType<typeof createTestEnvironment>>
let originalGetConfiguration: typeof vscode.workspace.getConfiguration
beforeEach(async () => {
env = await createTestEnvironment()
originalGetConfiguration = vscode.workspace.getConfiguration
// Mock VS Code configuration to disable checkpoints
vscode.workspace.getConfiguration = () =>
({
get: (key: string) => (key === "enableCheckpoints" ? false : undefined),
}) as any
})
afterEach(async () => {
await env.cleanup()
// Restore original configuration
vscode.workspace.getConfiguration = originalGetConfiguration
})
it("should return undefined when creating tracker", async () => {
const tracker = await createTestTracker(env.globalStoragePath)
expect(tracker).to.be.undefined
})
it("should allow re-enabling checkpoints", async () => {
// First verify disabled state
const disabledTracker = await createTestTracker(env.globalStoragePath)
expect(disabledTracker).to.be.undefined
// Mock configuration to enable checkpoints
vscode.workspace.getConfiguration = () =>
({
get: (key: string) => (key === "enableCheckpoints" ? true : undefined),
}) as any
// Verify tracker can be created when enabled
const enabledTracker = await createTestTracker(env.globalStoragePath)
expect(enabledTracker).to.not.be.undefined
// Verify operations work
if (!enabledTracker) {throw new Error("Failed to create tracker")}
await fs.writeFile(env.testFilePath, "test content")
const commit = await enabledTracker.commit()
expect(commit).to.be.a("string").and.not.empty
})
it("should prevent operations when disabled mid-session", async () => {
// Start with checkpoints enabled
vscode.workspace.getConfiguration = () =>
({
get: (key: string) => (key === "enableCheckpoints" ? true : undefined),
}) as any
// Create tracker and initial commit
const tracker = await createTestTracker(env.globalStoragePath)
expect(tracker).to.not.be.undefined
if (!tracker) {throw new Error("Failed to create tracker")}
await fs.writeFile(env.testFilePath, "initial content")
const firstCommit = await tracker.commit()
expect(firstCommit).to.be.a("string").and.not.empty
// Disable checkpoints
vscode.workspace.getConfiguration = () =>
({
get: (key: string) => (key === "enableCheckpoints" ? false : undefined),
}) as any
// Verify new tracker cannot be created
const disabledTracker = await createTestTracker(env.globalStoragePath)
expect(disabledTracker).to.be.undefined
// Verify existing tracker still works
// This is expected behavior since the tracker was created when enabled
await fs.writeFile(env.testFilePath, "modified content")
const secondCommit = await tracker.commit()
expect(secondCommit).to.be.a("string").and.not.empty
expect(secondCommit).to.not.equal(firstCommit)
// Verify diffs still work on existing tracker
const diffSet = await tracker.getDiffSet(firstCommit, secondCommit)
expect(diffSet).to.have.lengthOf(1)
expect(diffSet[0].before).to.equal("initial content")
expect(diffSet[0].after).to.equal("modified content")
})
})
@@ -1,325 +0,0 @@
import fs from "fs/promises"
import { join } from "path"
import { fileExistsAtPath } from "../../utils/fs"
import { GIT_DISABLED_SUFFIX } from "./CheckpointGitOperations"
/**
* CheckpointExclusions Module
*
* A specialized module within Cline's Checkpoints system that manages file exclusion rules
* for the checkpoint tracking process. It provides:
*
* File Filtering:
* - File types (build artifacts, media, cache files, etc.)
* - Git LFS patterns from workspace
* - Environment and configuration files
* - Temporary and cache files
*
* Pattern Management:
* - Extensible category-based pattern system
* - Comprehensive file type coverage
* - Easy pattern updates and maintenance
*
* Git Integration:
* - Seamless integration with Git's exclude mechanism
* - Support for workspace-specific LFS patterns
* - Automatic pattern updates during checkpoints
*
* The module ensures efficient checkpoint creation by preventing unnecessary tracking
* of large files, binary files, and temporary artifacts while maintaining a clean
* and organized checkpoint history.
*/
/**
* Returns the default list of file and directory patterns to exclude from checkpoints.
* Combines built-in patterns with workspace-specific LFS patterns.
*
* @param lfsPatterns - Optional array of Git LFS patterns from workspace
* @returns Array of glob patterns to exclude
* @todo Make this configurable by the user
*/
export const getDefaultExclusions = (lfsPatterns: string[] = []): string[] => [
// Build and Development Artifacts
".git/",
`.git${GIT_DISABLED_SUFFIX}/`,
...getBuildArtifactPatterns(),
// Media Files
...getMediaFilePatterns(),
// Cache and Temporary Files
...getCacheFilePatterns(),
// Environment and Config Files
...getConfigFilePatterns(),
// Large Data Files
...getLargeDataFilePatterns(),
// Database Files
...getDatabaseFilePatterns(),
// Geospatial Datasets
...getGeospatialPatterns(),
// Log Files
...getLogFilePatterns(),
...lfsPatterns,
]
/**
* Returns patterns for common build and development artifact directories
* @returns Array of glob patterns for build artifacts
*/
function getBuildArtifactPatterns(): string[] {
return [
".gradle/",
".idea/",
".parcel-cache/",
".pytest_cache/",
".next/",
".nuxt/",
".sass-cache/",
".vs/",
".vscode/",
"Pods/",
"__pycache__/",
"bin/",
"build/",
"bundle/",
"coverage/",
"deps/",
"dist/",
"env/",
"node_modules/",
"obj/",
"out/",
"pkg/",
"pycache/",
"target/dependency/",
"temp/",
"vendor/",
"venv/",
]
}
/**
* Returns patterns for common media and image file types
* @returns Array of glob patterns for media files
*/
function getMediaFilePatterns(): string[] {
return [
"*.jpg",
"*.jpeg",
"*.png",
"*.gif",
"*.bmp",
"*.ico",
"*.webp",
"*.tiff",
"*.tif",
// "*.svg",
"*.raw",
"*.heic",
"*.avif",
"*.eps",
"*.psd",
"*.3gp",
"*.aac",
"*.aiff",
"*.asf",
"*.avi",
"*.divx",
"*.flac",
"*.m4a",
"*.m4v",
"*.mkv",
"*.mov",
"*.mp3",
"*.mp4",
"*.mpeg",
"*.mpg",
"*.ogg",
"*.opus",
"*.rm",
"*.rmvb",
"*.vob",
"*.wav",
"*.webm",
"*.wma",
"*.wmv",
]
}
/**
* Returns patterns for cache, temporary, and system files
* @returns Array of glob patterns for cache files
*/
function getCacheFilePatterns(): string[] {
return [
"*.DS_Store",
"*.bak",
"*.cache",
"*.crdownload",
"*.dmp",
"*.dump",
"*.eslintcache",
"*.lock",
"*.log",
"*.old",
"*.part",
"*.partial",
"*.pyc",
"*.pyo",
"*.stackdump",
"*.swo",
"*.swp",
"*.temp",
"*.tmp",
"*.Thumbs.db",
]
}
/**
* Returns patterns for environment and configuration files
* @returns Array of glob patterns for config files
*/
function getConfigFilePatterns(): string[] {
return ["*.env*", "*.local", "*.development", "*.production"]
}
/**
* Returns patterns for common large binary and archive files
* @returns Array of glob patterns for large data files
*/
function getLargeDataFilePatterns(): string[] {
return [
"*.zip",
"*.tar",
"*.gz",
"*.rar",
"*.7z",
"*.iso",
"*.bin",
"*.exe",
"*.dll",
"*.so",
"*.dylib",
"*.dat",
"*.dmg",
"*.msi",
]
}
/**
* Returns patterns for database and data storage files
* @returns Array of glob patterns for database files
*/
function getDatabaseFilePatterns(): string[] {
return [
"*.arrow",
"*.accdb",
"*.aof",
"*.avro",
"*.bak",
"*.bson",
"*.csv",
"*.db",
"*.dbf",
"*.dmp",
"*.frm",
"*.ibd",
"*.mdb",
"*.myd",
"*.myi",
"*.orc",
"*.parquet",
"*.pdb",
"*.rdb",
"*.sql",
"*.sqlite",
]
}
/**
* Returns patterns for geospatial and mapping data files
* @returns Array of glob patterns for geospatial files
*/
function getGeospatialPatterns(): string[] {
return [
"*.shp",
"*.shx",
"*.dbf",
"*.prj",
"*.sbn",
"*.sbx",
"*.shp.xml",
"*.cpg",
"*.gdb",
"*.mdb",
"*.gpkg",
"*.kml",
"*.kmz",
"*.gml",
"*.geojson",
"*.dem",
"*.asc",
"*.img",
"*.ecw",
"*.las",
"*.laz",
"*.mxd",
"*.qgs",
"*.grd",
"*.csv",
"*.dwg",
"*.dxf",
]
}
/**
* Returns patterns for log and debug output files
* @returns Array of glob patterns for log files
*/
function getLogFilePatterns(): string[] {
return ["*.error", "*.log", "*.logs", "*.npm-debug.log*", "*.out", "*.stdout", "yarn-debug.log*", "yarn-error.log*"]
}
/**
* Writes the combined exclusion patterns to Git's exclude file.
* Creates the info directory if it doesn't exist.
*
* @param gitPath - Path to the .git directory
* @param lfsPatterns - Optional array of Git LFS patterns to include
*/
export const writeExcludesFile = async (gitPath: string, lfsPatterns: string[] = []): Promise<void> => {
const excludesPath = join(gitPath, "info", "exclude")
await fs.mkdir(join(gitPath, "info"), { recursive: true })
const patterns = getDefaultExclusions(lfsPatterns)
await fs.writeFile(excludesPath, patterns.join("\n"))
}
/**
* Retrieves Git LFS patterns from the workspace's .gitattributes file.
* Returns an empty array if no patterns found or file doesn't exist.
*
* @param workspacePath - Path to the workspace root
* @returns Array of Git LFS patterns found in .gitattributes
*/
export const getLfsPatterns = async (workspacePath: string): Promise<string[]> => {
try {
const attributesPath = join(workspacePath, ".gitattributes")
if (await fileExistsAtPath(attributesPath)) {
const attributesContent = await fs.readFile(attributesPath, "utf8")
return attributesContent
.split("\n")
.filter((line) => line.includes("filter=lfs"))
.map((line) => line.split(" ")[0].trim())
}
} catch (error) {
console.warn("Failed to read .gitattributes:", error)
}
return []
}
@@ -1,211 +0,0 @@
import fs from "fs/promises"
import { globby } from "globby"
import * as path from "path"
import simpleGit, { SimpleGit } from "simple-git"
import { fileExistsAtPath } from "../../utils/fs"
import { getLfsPatterns, writeExcludesFile } from "./CheckpointExclusions"
import { telemetryService } from "../../services/telemetry/TelemetryService"
interface CheckpointAddResult {
success: boolean
}
/**
* GitOperations Class
*
* Handles git-specific operations for Cline's Checkpoints system.
*
* Key responsibilities:
* - Git repository initialization and configuration
* - Git settings management (user, LFS, etc.)
* - Worktree configuration and management
* - Managing nested git repositories during checkpoint operations
* - File staging and checkpoint creation
* - Shadow git repository maintenance and cleanup
*/
export class GitOperations {
private cwd: string
/**
* Creates a new GitOperations instance.
*
* @param cwd - The current working directory for git operations
*/
constructor(cwd: string) {
this.cwd = cwd
}
/**
* Initializes or verifies a shadow Git repository for checkpoint tracking.
* Creates a new repository if one doesn't exist, or verifies the worktree
* configuration if it does.
*
* Key operations:
* - Creates/verifies shadow git repository
* - Configures git settings (user, LFS, etc.)
* - Sets up worktree to point to workspace
*
* @param gitPath - Path to the .git directory
* @param cwd - The current working directory for git operations
* @returns Promise<string> Path to the initialized .git directory
* @throws Error if:
* - Worktree verification fails for existing repository
* - Git initialization or configuration fails
* - Unable to create initial commit
* - LFS pattern setup fails
*/
public async initShadowGit(gitPath: string, cwd: string, taskId: string): Promise<string> {
console.info(`Initializing shadow git`)
// If repo exists, just verify worktree
if (await fileExistsAtPath(gitPath)) {
const git = simpleGit(path.dirname(gitPath))
const worktree = await git.getConfig("core.worktree")
if (worktree.value !== cwd) {
throw new Error("Checkpoints can only be used in the original workspace: " + worktree.value)
}
console.warn(`Using existing shadow git at ${gitPath}`)
// shadow git repo already exists, but update the excludes just in case
await writeExcludesFile(gitPath, await getLfsPatterns(this.cwd))
return gitPath
}
// Initialize new repo
const startTime = performance.now()
const checkpointsDir = path.dirname(gitPath)
console.warn(`Creating new shadow git in ${checkpointsDir}`)
const git = simpleGit(checkpointsDir)
await git.init()
// Configure repo with git settings
await git.addConfig("core.worktree", cwd)
await git.addConfig("commit.gpgSign", "false")
await git.addConfig("user.name", "Cline Checkpoint")
await git.addConfig("user.email", "checkpoint@cline.bot")
// Set up LFS patterns
const lfsPatterns = await getLfsPatterns(cwd)
await writeExcludesFile(gitPath, lfsPatterns)
await this.addCheckpointFiles(git)
// Initial commit only on first repo creation
await git.commit("initial commit", { "--allow-empty": null })
const durationMs = Math.round(performance.now() - startTime)
telemetryService.captureCheckpointUsage(taskId, "shadow_git_initialized", durationMs)
console.warn(`Shadow git initialization completed`)
return gitPath
}
/**
* Retrieves the worktree path from the shadow git configuration.
* The worktree path indicates where the shadow git repository is tracking files,
* which should match the current workspace directory.
*
* @param gitPath - Path to the .git directory
* @returns Promise<string | undefined> The worktree path or undefined if not found
* @throws Error if unable to get worktree path
*/
public async getShadowGitConfigWorkTree(gitPath: string): Promise<string | undefined> {
try {
const git = simpleGit(path.dirname(gitPath))
const worktree = await git.getConfig("core.worktree")
return worktree.value || undefined
} catch (error) {
console.error("Failed to get shadow git config worktree:", error)
return undefined
}
}
/**
* Since we use git to track checkpoints, we need to temporarily disable nested git repos to work around git's
* requirement of using submodules for nested repos.
*
* This method renames nested .git directories by adding/removing a suffix to temporarily disable/enable them.
* The root .git directory is preserved. Uses VS Code's workspace API to find nested .git directories and
* only processes actual directories (not files named .git).
*
* @param disable - If true, adds suffix to disable nested git repos. If false, removes suffix to re-enable them.
* @throws Error if renaming any .git directory fails
*/
public async renameNestedGitRepos(disable: boolean) {
// Find all .git directories that are not at the root level
const gitPaths = await globby("**/.git" + (disable ? "" : GIT_DISABLED_SUFFIX), {
cwd: this.cwd,
onlyDirectories: true,
ignore: [".git"], // Ignore root level .git
dot: true,
markDirectories: false,
})
// For each nested .git directory, rename it based on operation
for (const gitPath of gitPaths) {
const fullPath = path.join(this.cwd, gitPath)
let newPath: string
if (disable) {
newPath = fullPath + GIT_DISABLED_SUFFIX
} else {
newPath = fullPath.endsWith(GIT_DISABLED_SUFFIX) ? fullPath.slice(0, -GIT_DISABLED_SUFFIX.length) : fullPath
}
try {
await fs.rename(fullPath, newPath)
console.log(`CheckpointTracker ${disable ? "disabled" : "enabled"} nested git repo ${gitPath}`)
} catch (error) {
console.error(`CheckpointTracker failed to ${disable ? "disable" : "enable"} nested git repo ${gitPath}:`, error)
}
}
}
/**
* Adds files to the shadow git repository while handling nested git repos.
* Uses git commands to list files and stages them for commit.
* Respects .gitignore and handles LFS patterns.
*
* Process:
* 1. Updates exclude patterns from LFS config
* 2. Temporarily disables nested git repos
* 3. Gets list of tracked and untracked files from git (respecting .gitignore)
* 4. Adds all files to git staging
* 5. Re-enables nested git repos
*
* @param git - SimpleGit instance configured for the shadow git repo
* @returns Promise<CheckpointAddResult> Object containing success status, message, and file count
* @throws Error if:
* - File operations fail
* - Git commands error
* - LFS pattern updates fail
* - Nested git repo handling fails
*/
public async addCheckpointFiles(git: SimpleGit): Promise<CheckpointAddResult> {
const startTime = performance.now()
try {
// Update exclude patterns before each commit
await this.renameNestedGitRepos(true)
console.info("Starting checkpoint add operation...")
try {
await git.add(".")
const durationMs = Math.round(performance.now() - startTime)
console.debug(`Checkpoint add operation completed in ${durationMs}ms`)
return { success: true }
} catch (error) {
console.error("Checkpoint add operation failed:", error)
throw error
}
} catch (error) {
console.error("Failed to add files to checkpoint", error)
throw error
} finally {
await this.renameNestedGitRepos(false)
}
}
}
export const GIT_DISABLED_SUFFIX = "_disabled"
@@ -1,71 +0,0 @@
import fs from "fs/promises"
import * as path from "path"
import * as vscode from "vscode"
import { fileExistsAtPath } from "../../utils/fs"
/**
* Cleans up legacy checkpoints from task folders.
* This is a one-time operation that runs when the extension is updated to use the new checkpoint system.
*
* @param globalStoragePath - Path to the extension's global storage
* @param outputChannel - VSCode output channel for logging
*/
export async function cleanupLegacyCheckpoints(globalStoragePath: string, outputChannel: vscode.OutputChannel): Promise<void> {
try {
outputChannel.appendLine("Checking for legacy checkpoints...")
const tasksDir = path.join(globalStoragePath, "tasks")
// Check if tasks directory exists
if (!(await fileExistsAtPath(tasksDir))) {
return // No tasks directory, nothing to clean up
}
// Get all task folders
const taskFolders = await fs.readdir(tasksDir)
if (taskFolders.length === 0) {
return // No task folders, nothing to clean up
}
// Get stats for each folder to sort by creation time
const folderStats = await Promise.all(
taskFolders.map(async (folder) => {
const folderPath = path.join(tasksDir, folder)
const stats = await fs.stat(folderPath)
return { folder, path: folderPath, stats }
}),
)
// Sort by creation time, newest first
folderStats.sort((a, b) => b.stats.birthtimeMs - a.stats.birthtimeMs)
// Check if the most recent task folder has a checkpoints directory
if (folderStats.length > 0) {
const mostRecentFolder = folderStats[0]
const checkpointsDir = path.join(mostRecentFolder.path, "checkpoints")
if (await fileExistsAtPath(checkpointsDir)) {
outputChannel.appendLine("Found legacy checkpoints directory, cleaning up...")
// Legacy checkpoints found, delete checkpoints directories in all task folders
for (const folder of folderStats) {
const folderCheckpointsDir = path.join(folder.path, "checkpoints")
if (await fileExistsAtPath(folderCheckpointsDir)) {
outputChannel.appendLine(`Deleting legacy checkpoints in ${folder.folder}`)
try {
await fs.rm(folderCheckpointsDir, { recursive: true, force: true })
} catch (error) {
// Ignore error if directory removal fails
outputChannel.appendLine(`Warning: Failed to delete checkpoints in ${folder.folder}, continuing...`)
}
}
}
outputChannel.appendLine("Legacy checkpoints cleanup completed")
}
}
} catch (error) {
outputChannel.appendLine(`Error cleaning up legacy checkpoints: ${error}`)
console.error("Error cleaning up legacy checkpoints:", error)
}
}
@@ -1,95 +0,0 @@
import { expect } from "chai"
import { describe, it, beforeEach, afterEach } from "mocha"
import fs from "fs/promises"
import path from "path"
import { createTestEnvironment, createTestTracker } from "./Checkpoint-test-utils"
describe("Checkpoint Revert Operations", () => {
let env: Awaited<ReturnType<typeof createTestEnvironment>>
beforeEach(async () => {
env = await createTestEnvironment()
})
afterEach(async () => {
await env.cleanup()
})
it("should revert working directory to a previous checkpoint state", async () => {
const tracker = await createTestTracker(env.globalStoragePath)
if (!tracker) {throw new Error("Failed to create tracker")}
// Create and commit initial state
await fs.writeFile(env.testFilePath, "initial content")
const firstCommit = await tracker.commit()
expect(firstCommit).to.not.be.undefined
// Create and commit changes
await fs.writeFile(env.testFilePath, "modified content")
const secondCommit = await tracker.commit()
expect(secondCommit).to.not.be.undefined
// Make more changes without committing
await fs.writeFile(env.testFilePath, "uncommitted changes")
// Revert to first commit
await tracker.resetHead(firstCommit!)
// Verify file content matches initial state
const resetContent = await fs.readFile(env.testFilePath, "utf8")
expect(resetContent).to.equal("initial content")
})
it("should handle reverting with multiple files", async () => {
const tracker = await createTestTracker(env.globalStoragePath)
if (!tracker) {throw new Error("Failed to create tracker")}
// Create and commit initial state with multiple files
const testFile2Path = path.join(env.tempDir, "src", "test2.txt")
await fs.writeFile(env.testFilePath, "file1 initial")
await fs.writeFile(testFile2Path, "file2 initial")
const firstCommit = await tracker.commit()
expect(firstCommit).to.not.be.undefined
// Modify both files and commit
await fs.writeFile(env.testFilePath, "file1 modified")
await fs.writeFile(testFile2Path, "file2 modified")
const secondCommit = await tracker.commit()
expect(secondCommit).to.not.be.undefined
// Make more changes
await fs.writeFile(env.testFilePath, "file1 uncommitted")
await fs.writeFile(testFile2Path, "file2 uncommitted")
// Reset to first commit
await tracker.resetHead(firstCommit!)
// Verify both files match initial state
const file1Content = await fs.readFile(env.testFilePath, "utf8")
const file2Content = await fs.readFile(testFile2Path, "utf8")
expect(file1Content).to.equal("file1 initial")
expect(file2Content).to.equal("file2 initial")
})
it("should handle reverting when files are deleted", async () => {
const tracker = await createTestTracker(env.globalStoragePath)
if (!tracker) {throw new Error("Failed to create tracker")}
// Create and commit initial state
await fs.writeFile(env.testFilePath, "initial content")
const firstCommit = await tracker.commit()
expect(firstCommit).to.not.be.undefined
// Delete file and commit
await fs.unlink(env.testFilePath)
const secondCommit = await tracker.commit()
expect(secondCommit).to.not.be.undefined
// Revert to first commit
await tracker.resetHead(firstCommit!)
// Verify file is restored with original content
const resetContent = await fs.readFile(env.testFilePath, "utf8")
expect(resetContent).to.equal("initial content")
})
})
@@ -1,120 +0,0 @@
import { expect } from "chai"
import { describe, it, beforeEach, afterEach } from "mocha"
import fs from "fs/promises"
import { createTestEnvironment, createTestTracker } from "./Checkpoint-test-utils"
import { HistoryItem } from "../../shared/HistoryItem"
import CheckpointTracker from "./CheckpointTracker"
describe("Checkpoint Task Switching", () => {
let env: Awaited<ReturnType<typeof createTestEnvironment>>
let taskId1: string
let taskId2: string
let tracker1: CheckpointTracker | undefined
let tracker2: CheckpointTracker | undefined
beforeEach(async () => {
env = await createTestEnvironment()
taskId1 = "task-1"
taskId2 = "task-2"
tracker1 = await createTestTracker(env.globalStoragePath, taskId1)
if (!tracker1) {throw new Error("Failed to create tracker1")}
})
afterEach(async () => {
await env.cleanup()
})
it("should maintain separate history for each task", async () => {
if (!tracker1) {throw new Error("Failed to create tracker1")}
// Create and commit file in first task
await fs.writeFile(env.testFilePath, "task1 initial")
const task1Commit1 = await tracker1.commit()
expect(task1Commit1).to.be.a("string").and.not.empty
// Modify and commit again in first task
await fs.writeFile(env.testFilePath, "task1 modified")
const task1Commit2 = await tracker1.commit()
expect(task1Commit2).to.be.a("string").and.not.empty
// Create second task tracker
tracker2 = await createTestTracker(env.globalStoragePath, taskId2)
if (!tracker2) {throw new Error("Failed to create tracker2")}
// Create and commit file in second task
await fs.writeFile(env.testFilePath, "task2 initial")
const task2Commit1 = await tracker2.commit()
expect(task2Commit1).to.be.a("string").and.not.empty
// Create another commit to establish history
await fs.writeFile(env.testFilePath, "task2 modified")
const task2Commit2 = await tracker2.commit()
expect(task2Commit2).to.be.a("string").and.not.empty
// Verify second task's history
const task2Diff = await tracker2.getDiffSet(task2Commit1, task2Commit2)
expect(task2Diff).to.have.lengthOf(1)
expect(task2Diff[0].before).to.equal("task2 initial")
expect(task2Diff[0].after).to.equal("task2 modified")
// Switch back to first task by creating new tracker
const tracker1Again = await createTestTracker(env.globalStoragePath, taskId1)
if (!tracker1Again) {throw new Error("Failed to create tracker1Again")}
// Verify first task's history is preserved
const task1Diff = await tracker1Again.getDiffSet(task1Commit1, task1Commit2)
expect(task1Diff[0].before).to.equal("task1 initial")
expect(task1Diff[0].after).to.equal("task1 modified")
// Reset first task to initial state
if (!task1Commit1) {throw new Error("Failed to create initial commit")}
await tracker1Again.resetHead(task1Commit1)
const resetContent = await fs.readFile(env.testFilePath, "utf8")
expect(resetContent).to.equal("task1 initial")
})
it("should handle task deletion and recreation", async () => {
if (!tracker1) {throw new Error("Failed to create tracker1")}
// Create and commit file in first task
await fs.writeFile(env.testFilePath, "task1 content")
const task1Commit = await tracker1.commit()
expect(task1Commit).to.be.a("string").and.not.empty
// Create second task
tracker2 = await createTestTracker(env.globalStoragePath, taskId2)
if (!tracker2) {throw new Error("Failed to create tracker2")}
await fs.writeFile(env.testFilePath, "task2 content")
const task2Commit = await tracker2.commit()
expect(task2Commit).to.be.a("string").and.not.empty
// Delete second task's checkpoints
const historyItem: HistoryItem = {
id: `test-${Date.now()}`,
ts: Date.now(),
task: taskId2,
shadowGitConfigWorkTree: env.tempDir,
tokensIn: 0,
tokensOut: 0,
totalCost: 0,
}
await CheckpointTracker.deleteCheckpoints(taskId2, historyItem, env.globalStoragePath)
// Recreate second task
const tracker2Again = await createTestTracker(env.globalStoragePath, taskId2)
if (!tracker2Again) {throw new Error("Failed to create tracker2Again")}
// Create new commit in recreated task
await fs.writeFile(env.testFilePath, "task2 new content")
const newCommit = await tracker2Again.commit()
expect(newCommit).to.be.a("string").and.not.empty
// Switch back to first task and verify its history is intact
const tracker1Again = await createTestTracker(env.globalStoragePath, taskId1)
if (!tracker1Again) {throw new Error("Failed to create tracker1Again")}
if (!task1Commit) {throw new Error("Failed to create initial commit")}
await tracker1Again.resetHead(task1Commit)
const resetContent = await fs.readFile(env.testFilePath, "utf8")
expect(resetContent).to.equal("task1 content")
})
})
@@ -1,420 +0,0 @@
import fs from "fs/promises"
import os from "os"
import * as path from "path"
import simpleGit, { SimpleGit } from "simple-git"
import * as vscode from "vscode"
import { ClineProvider } from "../../core/webview/ClineProvider"
import { fileExistsAtPath } from "../../utils/fs"
import { globby } from "globby"
class CheckpointTracker {
private providerRef: WeakRef<ClineProvider>
private taskId: string
private disposables: vscode.Disposable[] = []
private cwd: string
private lastRetrievedShadowGitConfigWorkTree?: string
lastCheckpointHash?: string
private constructor(provider: ClineProvider, taskId: string, cwd: string) {
this.providerRef = new WeakRef(provider)
this.taskId = taskId
this.cwd = cwd
}
public static async create(taskId: string, provider?: ClineProvider): Promise<CheckpointTracker | undefined> {
try {
if (!provider) {
throw new Error("Provider is required to create a checkpoint tracker")
}
// Check if checkpoints are disabled in VS Code settings
const enableCheckpoints = vscode.workspace.getConfiguration("cline").get<boolean>("enableCheckpoints") ?? true
if (!enableCheckpoints) {
return undefined // Don't create tracker when disabled
}
// Check if git is installed by attempting to get version
try {
await simpleGit().version()
} catch (error) {
throw new Error("Git must be installed to use checkpoints.") // FIXME: must match what we check for in TaskHeader to show link
}
const cwd = await CheckpointTracker.getWorkingDirectory()
const newTracker = new CheckpointTracker(provider, taskId, cwd)
await newTracker.initShadowGit()
return newTracker
} catch (error) {
console.error("Failed to create CheckpointTracker:", error)
throw error
}
}
private static async getWorkingDirectory(): Promise<string> {
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
if (!cwd) {
throw new Error("No workspace detected. Please open Cline in a workspace to use checkpoints.")
}
const homedir = os.homedir()
const desktopPath = path.join(homedir, "Desktop")
const documentsPath = path.join(homedir, "Documents")
const downloadsPath = path.join(homedir, "Downloads")
switch (cwd) {
case homedir:
throw new Error("Cannot use checkpoints in home directory")
case desktopPath:
throw new Error("Cannot use checkpoints in Desktop directory")
case documentsPath:
throw new Error("Cannot use checkpoints in Documents directory")
case downloadsPath:
throw new Error("Cannot use checkpoints in Downloads directory")
default:
return cwd
}
}
private async getShadowGitPath(): Promise<string> {
const globalStoragePath = this.providerRef.deref()?.context.globalStorageUri.fsPath
if (!globalStoragePath) {
throw new Error("Global storage uri is invalid")
}
const checkpointsDir = path.join(globalStoragePath, "tasks", this.taskId, "checkpoints")
await fs.mkdir(checkpointsDir, { recursive: true })
const gitPath = path.join(checkpointsDir, ".git")
return gitPath
}
public static async doesShadowGitExist(taskId: string, provider?: ClineProvider): Promise<boolean> {
const globalStoragePath = provider?.context.globalStorageUri.fsPath
if (!globalStoragePath) {
return false
}
const gitPath = path.join(globalStoragePath, "tasks", taskId, "checkpoints", ".git")
return await fileExistsAtPath(gitPath)
}
public async initShadowGit(): Promise<string> {
const gitPath = await this.getShadowGitPath()
if (await fileExistsAtPath(gitPath)) {
// Make sure it's the same cwd as the configured worktree
const worktree = await this.getShadowGitConfigWorkTree()
if (worktree !== this.cwd) {
throw new Error("Checkpoints can only be used in the original workspace: " + worktree)
}
return gitPath
} else {
const checkpointsDir = path.dirname(gitPath)
const git = simpleGit(checkpointsDir)
await git.init()
await git.addConfig("core.worktree", this.cwd) // sets the working tree to the current workspace
// Disable commit signing for shadow repo
await git.addConfig("commit.gpgSign", "false")
// Get LFS patterns from workspace if they exist
let lfsPatterns: string[] = []
try {
const attributesPath = path.join(this.cwd, ".gitattributes")
if (await fileExistsAtPath(attributesPath)) {
const attributesContent = await fs.readFile(attributesPath, "utf8")
lfsPatterns = attributesContent
.split("\n")
.filter((line) => line.includes("filter=lfs"))
.map((line) => line.split(" ")[0].trim())
}
} catch (error) {
console.warn("Failed to read .gitattributes:", error)
}
// Add basic excludes directly in git config, while respecting any .gitignore in the workspace
// .git/info/exclude is local to the shadow git repo, so it's not shared with the main repo - and won't conflict with user's .gitignore
// TODO: let user customize these
const excludesPath = path.join(gitPath, "info", "exclude")
await fs.mkdir(path.join(gitPath, "info"), { recursive: true })
await fs.writeFile(
excludesPath,
[
".git/", // ignore the user's .git
`.git${GIT_DISABLED_SUFFIX}/`, // ignore the disabled nested git repos
".DS_Store",
"*.log",
"node_modules/",
"__pycache__/",
"env/",
"venv/",
"target/dependency/",
"build/dependencies/",
"dist/",
"out/",
"bundle/",
"vendor/",
"tmp/",
"temp/",
"deps/",
"pkg/",
"Pods/",
// Media files
"*.jpg",
"*.jpeg",
"*.png",
"*.gif",
"*.bmp",
"*.ico",
// "*.svg",
"*.mp3",
"*.mp4",
"*.wav",
"*.avi",
"*.mov",
"*.wmv",
"*.webm",
"*.webp",
"*.m4a",
"*.flac",
// Build and dependency directories
"build/",
"bin/",
"obj/",
".gradle/",
".idea/",
".vscode/",
".vs/",
"coverage/",
".next/",
".nuxt/",
// Cache and temporary files
"*.cache",
"*.tmp",
"*.temp",
"*.swp",
"*.swo",
"*.pyc",
"*.pyo",
".pytest_cache/",
".eslintcache",
// Environment and config files
".env*",
"*.local",
"*.development",
"*.production",
// Large data files
"*.zip",
"*.tar",
"*.gz",
"*.rar",
"*.7z",
"*.iso",
"*.bin",
"*.exe",
"*.dll",
"*.so",
"*.dylib",
// Database files
"*.sqlite",
"*.db",
"*.sql",
// Log files
"*.logs",
"*.error",
"npm-debug.log*",
"yarn-debug.log*",
"yarn-error.log*",
...lfsPatterns,
].join("\n"),
)
// Set up git identity (git throws an error if user.name or user.email is not set)
await git.addConfig("user.name", "Cline Checkpoint")
await git.addConfig("user.email", "noreply@example.com")
await this.addAllFiles(git)
// Initial commit (--allow-empty ensures it works even with no files)
await git.commit("initial commit", { "--allow-empty": null })
return gitPath
}
}
public async getShadowGitConfigWorkTree(): Promise<string | undefined> {
if (this.lastRetrievedShadowGitConfigWorkTree) {
return this.lastRetrievedShadowGitConfigWorkTree
}
try {
const gitPath = await this.getShadowGitPath()
const git = simpleGit(path.dirname(gitPath))
const worktree = await git.getConfig("core.worktree")
this.lastRetrievedShadowGitConfigWorkTree = worktree.value || undefined
return this.lastRetrievedShadowGitConfigWorkTree
} catch (error) {
console.error("Failed to get shadow git config worktree:", error)
return undefined
}
}
public async commit(): Promise<string | undefined> {
try {
const gitPath = await this.getShadowGitPath()
const git = simpleGit(path.dirname(gitPath))
await this.addAllFiles(git)
const result = await git.commit("checkpoint", {
"--allow-empty": null,
})
const commitHash = result.commit || ""
this.lastCheckpointHash = commitHash
return commitHash
} catch (error) {
console.error("Failed to create checkpoint:", error)
return undefined
}
}
public async resetHead(commitHash: string): Promise<void> {
const gitPath = await this.getShadowGitPath()
const git = simpleGit(path.dirname(gitPath))
// Clean working directory and force reset
// This ensures that the operation will succeed regardless of:
// - Untracked files in the workspace
// - Staged changes
// - Unstaged changes
// - Partial commits
// - Merge conflicts
await git.clean("f", ["-d", "-f"]) // Remove untracked files and directories
await git.reset(["--hard", commitHash]) // Hard reset to target commit
}
/**
* Return an array describing changed files between one commit and either:
* - another commit, or
* - the current working directory (including uncommitted changes).
*
* If `rhsHash` is omitted, compares `lhsHash` to the working directory.
* If you want truly untracked files to appear, `git add` them first.
*
* @param lhsHash - The commit to compare from (older commit)
* @param rhsHash - The commit to compare to (newer commit).
* If omitted, we compare to the working directory.
* @returns Array of file changes with before/after content
*/
public async getDiffSet(
lhsHash?: string,
rhsHash?: string,
): Promise<
Array<{
relativePath: string
absolutePath: string
before: string
after: string
}>
> {
const gitPath = await this.getShadowGitPath()
const git = simpleGit(path.dirname(gitPath))
// If lhsHash is missing, use the initial commit of the repo
let baseHash = lhsHash
if (!baseHash) {
const rootCommit = await git.raw(["rev-list", "--max-parents=0", "HEAD"])
baseHash = rootCommit.trim()
}
// Stage all changes so that untracked files appear in diff summary
await this.addAllFiles(git)
const diffSummary = rhsHash ? await git.diffSummary([`${baseHash}..${rhsHash}`]) : await git.diffSummary([baseHash])
// For each changed file, gather before/after content
const result = []
const cwdPath = (await this.getShadowGitConfigWorkTree()) || this.cwd || ""
for (const file of diffSummary.files) {
const filePath = file.file
const absolutePath = path.join(cwdPath, filePath)
let beforeContent = ""
try {
beforeContent = await git.show([`${baseHash}:${filePath}`])
} catch (_) {
// file didn't exist in older commit => remains empty
}
let afterContent = ""
if (rhsHash) {
// if user provided a newer commit, use git.show at that commit
try {
afterContent = await git.show([`${rhsHash}:${filePath}`])
} catch (_) {
// file didn't exist in newer commit => remains empty
}
} else {
// otherwise, read from disk (includes uncommitted changes)
try {
afterContent = await fs.readFile(absolutePath, "utf8")
} catch (_) {
// file might be deleted => remains empty
}
}
result.push({
relativePath: filePath,
absolutePath,
before: beforeContent,
after: afterContent,
})
}
return result
}
private async addAllFiles(git: SimpleGit) {
await this.renameNestedGitRepos(true)
try {
await git.add(".")
} catch (error) {
console.error("Failed to add files to git:", error)
} finally {
await this.renameNestedGitRepos(false)
}
}
// Since we use git to track checkpoints, we need to temporarily disable nested git repos to work around git's requirement of using submodules for nested repos.
private async renameNestedGitRepos(disable: boolean) {
// Find all .git directories that are not at the root level
const gitPaths = await globby("**/.git" + (disable ? "" : GIT_DISABLED_SUFFIX), {
cwd: this.cwd,
onlyDirectories: true,
ignore: [".git"], // Ignore root level .git
dot: true,
markDirectories: false,
})
// For each nested .git directory, rename it based on operation
for (const gitPath of gitPaths) {
const fullPath = path.join(this.cwd, gitPath)
let newPath: string
if (disable) {
newPath = fullPath + GIT_DISABLED_SUFFIX
} else {
newPath = fullPath.endsWith(GIT_DISABLED_SUFFIX) ? fullPath.slice(0, -GIT_DISABLED_SUFFIX.length) : fullPath
}
try {
await fs.rename(fullPath, newPath)
console.log(`CheckpointTracker ${disable ? "disabled" : "enabled"} nested git repo ${gitPath}`)
} catch (error) {
console.error(`CheckpointTracker failed to ${disable ? "disable" : "enable"} nested git repo ${gitPath}:`, error)
}
}
}
public dispose() {
this.disposables.forEach((d) => d.dispose())
this.disposables = []
}
}
const GIT_DISABLED_SUFFIX = "_disabled"
export default CheckpointTracker
+291 -230
View File
@@ -1,94 +1,31 @@
import fs from "fs/promises"
import os from "os"
import * as path from "path"
import simpleGit from "simple-git"
import simpleGit, { SimpleGit } from "simple-git"
import * as vscode from "vscode"
import { telemetryService } from "../../services/telemetry/TelemetryService"
import { GitOperations } from "./CheckpointGitOperations"
import { getShadowGitPath, getWorkingDirectory, hashWorkingDir } from "./CheckpointUtils"
/**
* CheckpointTracker Module
*
* Core implementation of Cline's Checkpoints system that provides version control
* capabilities without interfering with the user's main Git repository. Key features:
*
* Shadow Git Repository:
* - Creates and manages an isolated Git repository for tracking checkpoints
* - Handles nested Git repositories by temporarily disabling them
* - Configures Git settings automatically (identity, LFS, etc.)
*
* File Management:
* - Integrates with CheckpointExclusions for file filtering
* - Handles workspace validation and path resolution
* - Manages Git worktree configuration
*
* Checkpoint Operations:
* - Creates checkpoints (commits) of the current state
* - Provides diff capabilities between checkpoints
* - Supports resetting to previous checkpoints
*
* Safety Features:
* - Prevents usage in sensitive directories (home, desktop, etc.)
* - Validates workspace configuration
* - Handles cleanup and resource disposal
*
* Checkpoint Architecture:
* - Unique shadow git repository for each workspace
* - Workspaces are identified by name, and hashed to a unique number
* - All commits for a workspace are stored in one shadow git, under a single branch
*/
import { ClineProvider } from "../../core/webview/ClineProvider"
import { fileExistsAtPath } from "../../utils/fs"
import { globby } from "globby"
class CheckpointTracker {
private globalStoragePath: string
private providerRef: WeakRef<ClineProvider>
private taskId: string
private disposables: vscode.Disposable[] = []
private cwd: string
private cwdHash: string
private lastRetrievedShadowGitConfigWorkTree?: string
private gitOperations: GitOperations
lastCheckpointHash?: string
/**
* Creates a new CheckpointTracker instance to manage checkpoints for a specific task.
* The constructor is private - use the static create() method to instantiate.
*
* @param taskId - Unique identifier for the task being tracked
* @param cwd - The current working directory to track files in
* @param cwdHash - Hash of the working directory path for shadow git organization
*/
private constructor(globalStoragePath: string, taskId: string, cwd: string, cwdHash: string) {
this.globalStoragePath = globalStoragePath
private constructor(provider: ClineProvider, taskId: string, cwd: string) {
this.providerRef = new WeakRef(provider)
this.taskId = taskId
this.cwd = cwd
this.cwdHash = cwdHash
this.gitOperations = new GitOperations(cwd)
}
/**
* Creates a new CheckpointTracker instance for tracking changes in a task.
* Handles initialization of the shadow git repository.
*
* @param taskId - Unique identifier for the task to track
* @param globalStoragePath - the globalStorage path
* @returns Promise resolving to new CheckpointTracker instance, or undefined if checkpoints are disabled
* @throws Error if:
* - globalStoragePath is not supplied
* - Git is not installed
* - Working directory is invalid or in a protected location
* - Shadow git initialization fails
*
* Key operations:
* - Validates git installation and settings
* - Creates/initializes shadow git repository
*
* Configuration:
* - Respects 'cline.enableCheckpoints' VS Code setting
*/
public static async create(taskId: string, globalStoragePath: string | undefined): Promise<CheckpointTracker | undefined> {
if (!globalStoragePath) {
throw new Error("Global storage path is required to create a checkpoint tracker")
}
public static async create(taskId: string, provider?: ClineProvider): Promise<CheckpointTracker | undefined> {
try {
console.info(`Creating new CheckpointTracker for task ${taskId}`)
const startTime = performance.now()
if (!provider) {
throw new Error("Provider is required to create a checkpoint tracker")
}
// Check if checkpoints are disabled in VS Code settings
const enableCheckpoints = vscode.workspace.getConfiguration("cline").get<boolean>("enableCheckpoints") ?? true
@@ -103,18 +40,9 @@ class CheckpointTracker {
throw new Error("Git must be installed to use checkpoints.") // FIXME: must match what we check for in TaskHeader to show link
}
const workingDir = await getWorkingDirectory()
const cwdHash = hashWorkingDir(workingDir)
console.debug(`Repository ID (cwdHash): ${cwdHash}`)
const newTracker = new CheckpointTracker(globalStoragePath, taskId, workingDir, cwdHash)
const gitPath = await getShadowGitPath(newTracker.globalStoragePath, newTracker.taskId, newTracker.cwdHash)
await newTracker.gitOperations.initShadowGit(gitPath, workingDir, taskId)
const durationMs = Math.round(performance.now() - startTime)
telemetryService.captureCheckpointUsage(taskId, "shadow_git_initialized", durationMs)
const cwd = await CheckpointTracker.getWorkingDirectory()
const newTracker = new CheckpointTracker(provider, taskId, cwd)
await newTracker.initShadowGit()
return newTracker
} catch (error) {
console.error("Failed to create CheckpointTracker:", error)
@@ -122,95 +50,203 @@ class CheckpointTracker {
}
}
/**
* Creates a new checkpoint commit in the shadow git repository.
*
* Key behaviors:
* - Creates commit with checkpoint files in shadow git repo
* - Caches the created commit hash
*
* Commit structure:
* - Commit message: "checkpoint-{cwdHash}-{taskId}"
* - Always allows empty commits
*
* Dependencies:
* - Requires initialized shadow git (getShadowGitPath)
* - Uses addCheckpointFiles to stage changes using 'git add .'
* - Relies on git's native exclusion handling via the exclude file
*
* @returns Promise<string | undefined> The created commit hash, or undefined if:
* - Shadow git access fails
* - Staging files fails
* - Commit creation fails
* @throws Error if unable to:
* - Access shadow git path
* - Initialize simple-git
* - Stage or commit files
*/
public async commit(): Promise<string | undefined> {
try {
console.info(`Creating new checkpoint commit for task ${this.taskId}`)
const startTime = performance.now()
private static async getWorkingDirectory(): Promise<string> {
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
if (!cwd) {
throw new Error("No workspace detected. Please open Cline in a workspace to use checkpoints.")
}
const homedir = os.homedir()
const desktopPath = path.join(homedir, "Desktop")
const documentsPath = path.join(homedir, "Documents")
const downloadsPath = path.join(homedir, "Downloads")
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash)
const git = simpleGit(path.dirname(gitPath))
console.info(`Using shadow git at: ${gitPath}`)
await this.gitOperations.addCheckpointFiles(git)
const commitMessage = "checkpoint-" + this.cwdHash + "-" + this.taskId
console.info(`Creating checkpoint commit with message: ${commitMessage}`)
const result = await git.commit(commitMessage, {
"--allow-empty": null,
})
const commitHash = result.commit || ""
console.warn(`Checkpoint commit created.`)
const durationMs = Math.round(performance.now() - startTime)
telemetryService.captureCheckpointUsage(this.taskId, "commit_created", durationMs)
return commitHash
} catch (error) {
console.error("Failed to create checkpoint:", {
taskId: this.taskId,
error,
})
throw new Error(`Failed to create checkpoint: ${error instanceof Error ? error.message : String(error)}`)
switch (cwd) {
case homedir:
throw new Error("Cannot use checkpoints in home directory")
case desktopPath:
throw new Error("Cannot use checkpoints in Desktop directory")
case documentsPath:
throw new Error("Cannot use checkpoints in Documents directory")
case downloadsPath:
throw new Error("Cannot use checkpoints in Downloads directory")
default:
return cwd
}
}
private async getShadowGitPath(): Promise<string> {
const globalStoragePath = this.providerRef.deref()?.context.globalStorageUri.fsPath
if (!globalStoragePath) {
throw new Error("Global storage uri is invalid")
}
const checkpointsDir = path.join(globalStoragePath, "tasks", this.taskId, "checkpoints")
await fs.mkdir(checkpointsDir, { recursive: true })
const gitPath = path.join(checkpointsDir, ".git")
return gitPath
}
public static async doesShadowGitExist(taskId: string, provider?: ClineProvider): Promise<boolean> {
const globalStoragePath = provider?.context.globalStorageUri.fsPath
if (!globalStoragePath) {
return false
}
const gitPath = path.join(globalStoragePath, "tasks", taskId, "checkpoints", ".git")
return await fileExistsAtPath(gitPath)
}
public async initShadowGit(): Promise<string> {
const gitPath = await this.getShadowGitPath()
if (await fileExistsAtPath(gitPath)) {
// Make sure it's the same cwd as the configured worktree
const worktree = await this.getShadowGitConfigWorkTree()
if (worktree !== this.cwd) {
throw new Error("Checkpoints can only be used in the original workspace: " + worktree)
}
return gitPath
} else {
const checkpointsDir = path.dirname(gitPath)
const git = simpleGit(checkpointsDir)
await git.init()
await git.addConfig("core.worktree", this.cwd) // sets the working tree to the current workspace
// Disable commit signing for shadow repo
await git.addConfig("commit.gpgSign", "false")
// Get LFS patterns from workspace if they exist
let lfsPatterns: string[] = []
try {
const attributesPath = path.join(this.cwd, ".gitattributes")
if (await fileExistsAtPath(attributesPath)) {
const attributesContent = await fs.readFile(attributesPath, "utf8")
lfsPatterns = attributesContent
.split("\n")
.filter((line) => line.includes("filter=lfs"))
.map((line) => line.split(" ")[0].trim())
}
} catch (error) {
console.warn("Failed to read .gitattributes:", error)
}
// Add basic excludes directly in git config, while respecting any .gitignore in the workspace
// .git/info/exclude is local to the shadow git repo, so it's not shared with the main repo - and won't conflict with user's .gitignore
// TODO: let user customize these
const excludesPath = path.join(gitPath, "info", "exclude")
await fs.mkdir(path.join(gitPath, "info"), { recursive: true })
await fs.writeFile(
excludesPath,
[
".git/", // ignore the user's .git
`.git${GIT_DISABLED_SUFFIX}/`, // ignore the disabled nested git repos
".DS_Store",
"*.log",
"node_modules/",
"__pycache__/",
"env/",
"venv/",
"target/dependency/",
"build/dependencies/",
"dist/",
"out/",
"bundle/",
"vendor/",
"tmp/",
"temp/",
"deps/",
"pkg/",
"Pods/",
// Media files
"*.jpg",
"*.jpeg",
"*.png",
"*.gif",
"*.bmp",
"*.ico",
// "*.svg",
"*.mp3",
"*.mp4",
"*.wav",
"*.avi",
"*.mov",
"*.wmv",
"*.webm",
"*.webp",
"*.m4a",
"*.flac",
// Build and dependency directories
"build/",
"bin/",
"obj/",
".gradle/",
".idea/",
".vscode/",
".vs/",
"coverage/",
".next/",
".nuxt/",
// Cache and temporary files
"*.cache",
"*.tmp",
"*.temp",
"*.swp",
"*.swo",
"*.pyc",
"*.pyo",
".pytest_cache/",
".eslintcache",
// Environment and config files
".env*",
"*.local",
"*.development",
"*.production",
// Large data files
"*.zip",
"*.tar",
"*.gz",
"*.rar",
"*.7z",
"*.iso",
"*.bin",
"*.exe",
"*.dll",
"*.so",
"*.dylib",
// Database files
"*.sqlite",
"*.db",
"*.sql",
// Log files
"*.logs",
"*.error",
"npm-debug.log*",
"yarn-debug.log*",
"yarn-error.log*",
...lfsPatterns,
].join("\n"),
)
// Set up git identity (git throws an error if user.name or user.email is not set)
await git.addConfig("user.name", "Cline Checkpoint")
await git.addConfig("user.email", "noreply@example.com")
await this.addAllFiles(git)
// Initial commit (--allow-empty ensures it works even with no files)
await git.commit("initial commit", { "--allow-empty": null })
return gitPath
}
}
/**
* Retrieves the worktree path from the shadow git configuration.
* The worktree path indicates where the shadow git repository is tracking files,
* which should match the current workspace directory.
*
* Key behaviors:
* - Caches result in lastRetrievedShadowGitConfigWorkTree to avoid repeated reads
* - Returns cached value if available
* - Reads git config if no cached value exists
*
* Configuration read:
* - Uses simple-git to read core.worktree config
* - Operates on shadow git at path from getShadowGitPath()
*
* @returns Promise<string | undefined> The configured worktree path, or undefined if:
* - Shadow git repository doesn't exist
* - Config read fails
* - No worktree is configured
* @throws Error if unable to:
* - Access shadow git path
* - Initialize simple-git
* - Read git configuration
*/
public async getShadowGitConfigWorkTree(): Promise<string | undefined> {
if (this.lastRetrievedShadowGitConfigWorkTree) {
return this.lastRetrievedShadowGitConfigWorkTree
}
try {
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash)
this.lastRetrievedShadowGitConfigWorkTree = await this.gitOperations.getShadowGitConfigWorkTree(gitPath)
const gitPath = await this.getShadowGitPath()
const git = simpleGit(path.dirname(gitPath))
const worktree = await git.getConfig("core.worktree")
this.lastRetrievedShadowGitConfigWorkTree = worktree.value || undefined
return this.lastRetrievedShadowGitConfigWorkTree
} catch (error) {
console.error("Failed to get shadow git config worktree:", error)
@@ -218,34 +254,36 @@ class CheckpointTracker {
}
}
/**
* Resets the shadow git repository's HEAD to a specific checkpoint commit.
* This will discard all changes after the target commit and restore the
* working directory to that checkpoint's state.
*
* Dependencies:
* - Requires initialized shadow git (getShadowGitPath)
* - Must be called with a valid commit hash from this task's history
*
* @param commitHash - The hash of the checkpoint commit to reset to
* @returns Promise<void> Resolves when reset is complete
* @throws Error if unable to:
* - Access shadow git path
* - Initialize simple-git
* - Reset to target commit
*/
public async commit(): Promise<string | undefined> {
try {
const gitPath = await this.getShadowGitPath()
const git = simpleGit(path.dirname(gitPath))
await this.addAllFiles(git)
const result = await git.commit("checkpoint", {
"--allow-empty": null,
})
const commitHash = result.commit || ""
this.lastCheckpointHash = commitHash
return commitHash
} catch (error) {
console.error("Failed to create checkpoint:", error)
return undefined
}
}
public async resetHead(commitHash: string): Promise<void> {
console.info(`Resetting to checkpoint: ${commitHash}`)
const startTime = performance.now()
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash)
const gitPath = await this.getShadowGitPath()
const git = simpleGit(path.dirname(gitPath))
console.debug(`Using shadow git at: ${gitPath}`)
await git.reset(["--hard", commitHash]) // Hard reset to target commit
console.debug(`Successfully reset to checkpoint: ${commitHash}`)
const durationMs = Math.round(performance.now() - startTime)
telemetryService.captureCheckpointUsage(this.taskId, "restored", durationMs)
// Clean working directory and force reset
// This ensures that the operation will succeed regardless of:
// - Untracked files in the workspace
// - Staged changes
// - Unstaged changes
// - Partial commits
// - Merge conflicts
await git.clean("f", ["-d", "-f"]) // Remove untracked files and directories
await git.reset(["--hard", commitHash]) // Hard reset to target commit
}
/**
@@ -262,7 +300,7 @@ class CheckpointTracker {
* @returns Array of file changes with before/after content
*/
public async getDiffSet(
lhsHash: string,
lhsHash?: string,
rhsHash?: string,
): Promise<
Array<{
@@ -272,40 +310,46 @@ class CheckpointTracker {
after: string
}>
> {
const startTime = performance.now()
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash)
const gitPath = await this.getShadowGitPath()
const git = simpleGit(path.dirname(gitPath))
console.info(`Getting diff between commits: ${lhsHash || "initial"} -> ${rhsHash || "working directory"}`)
// If lhsHash is missing, use the initial commit of the repo
let baseHash = lhsHash
if (!baseHash) {
const rootCommit = await git.raw(["rev-list", "--max-parents=0", "HEAD"])
baseHash = rootCommit.trim()
}
// Stage all changes so that untracked files appear in diff summary
await this.gitOperations.addCheckpointFiles(git)
await this.addAllFiles(git)
const diffRange = rhsHash ? `${lhsHash}..${rhsHash}` : lhsHash
console.info(`Diff range: ${diffRange}`)
const diffSummary = await git.diffSummary([diffRange])
const diffSummary = rhsHash ? await git.diffSummary([`${baseHash}..${rhsHash}`]) : await git.diffSummary([baseHash])
// For each changed file, gather before/after content
const result = []
const cwdPath = (await this.getShadowGitConfigWorkTree()) || this.cwd || ""
for (const file of diffSummary.files) {
const filePath = file.file
const absolutePath = path.join(this.cwd, filePath)
const absolutePath = path.join(cwdPath, filePath)
let beforeContent = ""
try {
beforeContent = await git.show([`${lhsHash}:${filePath}`])
beforeContent = await git.show([`${baseHash}:${filePath}`])
} catch (_) {
// file didn't exist in older commit => remains empty
}
let afterContent = ""
if (rhsHash) {
// if user provided a newer commit, use git.show at that commit
try {
afterContent = await git.show([`${rhsHash}:${filePath}`])
} catch (_) {
// file didn't exist in newer commit => remains empty
}
} else {
// otherwise, read from disk (includes uncommitted changes)
try {
afterContent = await fs.readFile(absolutePath, "utf8")
} catch (_) {
@@ -321,39 +365,56 @@ class CheckpointTracker {
})
}
const durationMs = Math.round(performance.now() - startTime)
telemetryService.captureCheckpointUsage(this.taskId, "diff_generated", durationMs)
return result
}
/**
* Returns the number of files changed between two commits.
*
* @param lhsHash - The commit to compare from (older commit)
* @param rhsHash - The commit to compare to (newer commit).
* If omitted, we compare to the working directory.
* @returns The number of files changed between the commits
*/
public async getDiffCount(lhsHash: string, rhsHash?: string): Promise<number> {
const startTime = performance.now()
private async addAllFiles(git: SimpleGit) {
await this.renameNestedGitRepos(true)
try {
await git.add(".")
} catch (error) {
console.error("Failed to add files to git:", error)
} finally {
await this.renameNestedGitRepos(false)
}
}
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash)
const git = simpleGit(path.dirname(gitPath))
// Since we use git to track checkpoints, we need to temporarily disable nested git repos to work around git's requirement of using submodules for nested repos.
private async renameNestedGitRepos(disable: boolean) {
// Find all .git directories that are not at the root level
const gitPaths = await globby("**/.git" + (disable ? "" : GIT_DISABLED_SUFFIX), {
cwd: this.cwd,
onlyDirectories: true,
ignore: [".git"], // Ignore root level .git
dot: true,
markDirectories: false,
})
console.info(`Getting diff count between commits: ${lhsHash || "initial"} -> ${rhsHash || "working directory"}`)
// For each nested .git directory, rename it based on operation
for (const gitPath of gitPaths) {
const fullPath = path.join(this.cwd, gitPath)
let newPath: string
if (disable) {
newPath = fullPath + GIT_DISABLED_SUFFIX
} else {
newPath = fullPath.endsWith(GIT_DISABLED_SUFFIX) ? fullPath.slice(0, -GIT_DISABLED_SUFFIX.length) : fullPath
}
// Stage all changes so that untracked files appear in diff summary
await this.gitOperations.addCheckpointFiles(git)
try {
await fs.rename(fullPath, newPath)
console.log(`CheckpointTracker ${disable ? "disabled" : "enabled"} nested git repo ${gitPath}`)
} catch (error) {
console.error(`CheckpointTracker failed to ${disable ? "disable" : "enable"} nested git repo ${gitPath}:`, error)
}
}
}
const diffRange = rhsHash ? `${lhsHash}..${rhsHash}` : lhsHash
const diffSummary = await git.diffSummary([diffRange])
const durationMs = Math.round(performance.now() - startTime)
telemetryService.captureCheckpointUsage(this.taskId, "diff_generated", durationMs)
return diffSummary.files.length
public dispose() {
this.disposables.forEach((d) => d.dispose())
this.disposables = []
}
}
const GIT_DISABLED_SUFFIX = "_disabled"
export default CheckpointTracker
@@ -1,86 +0,0 @@
import { mkdir } from "fs/promises"
import * as path from "path"
import * as vscode from "vscode"
import os from "os"
/**
* Gets the path to the shadow Git repository in globalStorage.
*
* Checkpoints path structure:
* globalStorage/
* checkpoints/
* {cwdHash}/
* .git/
*
* @param globalStoragePath - The VS Code global storage path
* @param taskId - The ID of the task
* @param cwdHash - Hash of the working directory path
* @returns Promise<string> The absolute path to the shadow git directory
* @throws Error if global storage path is invalid
*/
export async function getShadowGitPath(globalStoragePath: string, taskId: string, cwdHash: string): Promise<string> {
if (!globalStoragePath) {
throw new Error("Global storage uri is invalid")
}
const checkpointsDir = path.join(globalStoragePath, "checkpoints", cwdHash)
await mkdir(checkpointsDir, { recursive: true })
const gitPath = path.join(checkpointsDir, ".git")
return gitPath
}
/**
* Gets the current working directory from the VS Code workspace.
* Validates that checkpoints are not being used in protected directories
* like home, Desktop, Documents, or Downloads.
*
* Protected directories:
* - User's home directory
* - Desktop
* - Documents
* - Downloads
*
* @returns Promise<string> The absolute path to the current working directory
* @throws Error if no workspace is detected or if in a protected directory
*/
export async function getWorkingDirectory(): Promise<string> {
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
if (!cwd) {
throw new Error("No workspace detected. Please open Cline in a workspace to use checkpoints.")
}
const homedir = os.homedir()
const desktopPath = path.join(homedir, "Desktop")
const documentsPath = path.join(homedir, "Documents")
const downloadsPath = path.join(homedir, "Downloads")
switch (cwd) {
case homedir:
throw new Error("Cannot use checkpoints in home directory")
case desktopPath:
throw new Error("Cannot use checkpoints in Desktop directory")
case documentsPath:
throw new Error("Cannot use checkpoints in Documents directory")
case downloadsPath:
throw new Error("Cannot use checkpoints in Downloads directory")
default:
return cwd
}
}
/**
* Hashes the current working directory to a 13-character numeric hash.
* @param workingDir - The absolute path to the working directory
* @returns A 13-character numeric hash string used to identify the workspace
* @throws {Error} If the working directory path is empty or invalid
*/
export function hashWorkingDir(workingDir: string): string {
if (!workingDir) {
throw new Error("Working directory path cannot be empty")
}
let hash = 0
for (let i = 0; i < workingDir.length; i++) {
hash = (hash * 31 + workingDir.charCodeAt(i)) >>> 0
}
const bigHash = BigInt(hash)
const numericHash = bigHash.toString().slice(0, 13)
return numericHash
}
+26 -15
View File
@@ -35,26 +35,21 @@ export async function downloadTask(dateTs: number, conversationHistory: Anthropi
})
if (saveUri) {
try {
// Write content to the selected location
await vscode.workspace.fs.writeFile(saveUri, new TextEncoder().encode(markdownContent))
vscode.window.showTextDocument(saveUri, { preview: true })
} catch (error) {
vscode.window.showErrorMessage(
`Failed to save markdown file: ${error instanceof Error ? error.message : String(error)}`,
)
}
// Write content to the selected location
await vscode.workspace.fs.writeFile(saveUri, Buffer.from(markdownContent))
vscode.window.showTextDocument(saveUri, { preview: true })
}
}
export function formatContentBlockToMarkdown(block: Anthropic.ContentBlockParam): string {
export function formatContentBlockToMarkdown(
block: Anthropic.TextBlockParam | Anthropic.ImageBlockParam | Anthropic.ToolUseBlockParam | Anthropic.ToolResultBlockParam,
// messages: Anthropic.MessageParam[]
): string {
switch (block.type) {
case "text":
return block.text
case "image":
return `[Image]`
case "document":
return `[Document]`
case "tool_use":
let input: string
if (typeof block.input === "object" && block.input !== null) {
@@ -66,16 +61,32 @@ export function formatContentBlockToMarkdown(block: Anthropic.ContentBlockParam)
}
return `[Tool Use: ${block.name}]\n${input}`
case "tool_result":
// For now we're not doing tool name lookup since we don't use tools anymore
// const toolName = findToolName(block.tool_use_id, messages)
const toolName = "Tool"
if (typeof block.content === "string") {
return `[Tool${block.is_error ? " (Error)" : ""}]\n${block.content}`
return `[${toolName}${block.is_error ? " (Error)" : ""}]\n${block.content}`
} else if (Array.isArray(block.content)) {
return `[Tool${block.is_error ? " (Error)" : ""}]\n${block.content
return `[${toolName}${block.is_error ? " (Error)" : ""}]\n${block.content
.map((contentBlock) => formatContentBlockToMarkdown(contentBlock))
.join("\n")}`
} else {
return `[Tool${block.is_error ? " (Error)" : ""}]`
return `[${toolName}${block.is_error ? " (Error)" : ""}]`
}
default:
return "[Unexpected content type]"
}
}
export function findToolName(toolCallId: string, messages: Anthropic.MessageParam[]): string {
for (const message of messages) {
if (Array.isArray(message.content)) {
for (const block of message.content) {
if (block.type === "tool_use" && block.id === toolCallId) {
return block.name
}
}
}
}
return "Unknown Tool"
}
-6
View File
@@ -4,7 +4,6 @@ import pdf from "pdf-parse/lib/pdf-parse"
import mammoth from "mammoth"
import fs from "fs/promises"
import { isBinaryFile } from "isbinaryfile"
import { getFileSizeInKB } from "../../utils/fs"
export async function extractTextFromFile(filePath: string): Promise<string> {
try {
@@ -23,11 +22,6 @@ export async function extractTextFromFile(filePath: string): Promise<string> {
default:
const isBinary = await isBinaryFile(filePath).catch(() => false)
if (!isBinary) {
// If file is over 300KB, throw an error
const fileSizeInKB = await getFileSizeInKB(filePath)
if (fileSizeInKB > 300) {
throw new Error(`File is too large to read into context.`)
}
return await fs.readFile(filePath, "utf8")
} else {
throw new Error(`Cannot read text for file type: ${fileExtension}`)
-107
View File
@@ -1,107 +0,0 @@
import axios from "axios"
import ogs from "open-graph-scraper"
export interface OpenGraphData {
title?: string
description?: string
image?: string
url?: string
siteName?: string
type?: string
}
/**
* Fetches Open Graph metadata from a URL
* @param url The URL to fetch metadata from
* @returns Promise resolving to OpenGraphData
*/
export async function fetchOpenGraphData(url: string): Promise<OpenGraphData> {
try {
const options = {
url: url,
timeout: 5000,
headers: {
"user-agent": "Mozilla/5.0 (compatible; VSCodeExtension/1.0; +https://cline.bot)",
},
onlyGetOpenGraphInfo: false, // Get all metadata, not just Open Graph
fetchOptions: {
redirect: "follow", // Follow redirects
} as any,
}
const { result } = await ogs(options)
// Use type assertion to avoid TypeScript errors
const data = result as any
// Handle image URLs
let imageUrl = data.ogImage?.[0]?.url || data.twitterImage?.[0]?.url
// If the image URL is relative, make it absolute
if (imageUrl && (imageUrl.startsWith("/") || imageUrl.startsWith("./"))) {
try {
// Extract the base URL and make the relative URL absolute
const urlObj = new URL(url)
const baseUrl = `${urlObj.protocol}//${urlObj.hostname}`
imageUrl = new URL(imageUrl, baseUrl).href
} catch (error) {
console.error(`Error converting relative URL to absolute: ${imageUrl}`, error)
}
}
return {
title: data.ogTitle || data.twitterTitle || data.dcTitle || data.title || new URL(url).hostname,
description:
data.ogDescription ||
data.twitterDescription ||
data.dcDescription ||
data.description ||
"No description available",
image: imageUrl,
url: data.ogUrl || url,
siteName: data.ogSiteName || new URL(url).hostname,
type: data.ogType,
}
} catch (error) {
console.error(`Error fetching Open Graph data for ${url}:`, error)
// Return basic information based on the URL
try {
const urlObj = new URL(url)
return {
title: urlObj.hostname,
description: url,
url: url,
siteName: urlObj.hostname,
}
} catch {
return {
title: url,
description: url,
url: url,
}
}
}
}
/**
* Checks if a URL is an image by making a HEAD request and checking the content type
* @param url The URL to check
* @returns Promise resolving to boolean indicating if the URL is an image
*/
export async function isImageUrl(url: string): Promise<boolean> {
try {
const response = await axios.head(url, {
headers: {
"User-Agent": "Mozilla/5.0 (compatible; VSCodeExtension/1.0; +https://cline.bot)",
},
timeout: 3000,
})
const contentType = response.headers["content-type"]
return contentType && contentType.startsWith("image/")
} catch (error) {
console.error(`Error checking if URL is an image: ${url}`, error)
// If we can't determine, fall back to checking the file extension
return /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(url)
}
}
@@ -1,398 +0,0 @@
import { describe, it, beforeEach, afterEach } from "mocha"
import "should"
import * as sinon from "sinon"
import { TerminalProcess } from "./TerminalProcess"
import * as vscode from "vscode"
import { TerminalRegistry } from "./TerminalRegistry"
import { EventEmitter } from "events"
declare module "vscode" {
// https://github.com/microsoft/vscode/blob/f0417069c62e20f3667506f4b7e53ca0004b4e3e/src/vscode-dts/vscode.d.ts#L7442
interface Terminal {
shellIntegration?: {
cwd?: vscode.Uri
executeCommand?: (command: string) => {
read: () => AsyncIterable<string>
}
}
}
}
// Create a mock stream for simulating terminal output - this is only used for tests
// that need controlled output which can't be guaranteed with real terminals
function createMockStream(lines: string[] = ["test-command", "line1", "line2", "line3"]) {
return {
async *[Symbol.asyncIterator]() {
for (const line of lines) {
yield line + "\n"
}
},
}
}
describe("TerminalProcess (Integration Tests)", () => {
let process: TerminalProcess
let sandbox: sinon.SinonSandbox
let createdTerminals: vscode.Terminal[] = []
beforeEach(() => {
sandbox = sinon.createSandbox({ useFakeTimers: true })
process = new TerminalProcess()
})
afterEach(() => {
// Restore sandbox, which restores timers and all Sinon fakes
sandbox.restore()
// Remove any event listeners left on the TerminalProcess
process.removeAllListeners()
// Dispose all terminals created during the test
createdTerminals.forEach((t) => t.dispose())
createdTerminals = []
})
describe("Real terminal tests", () => {
// This test works with or without shell integration
it("should create and run a command in a real terminal", async () => {
// Create a real VS Code terminal for testing
const terminal = TerminalRegistry.createTerminal().terminal
createdTerminals.push(terminal)
// Spy on emit to verify behavior
const emitSpy = sandbox.spy(process, "emit")
// Run a simple command
await process.run(terminal, "echo test")
// Verify that the continue event was emitted
;(emitSpy as sinon.SinonSpy).calledWith("continue").should.be.true()
;(emitSpy as sinon.SinonSpy).calledWith("completed").should.be.true()
})
it("should execute and capture events from a simple command", async () => {
// Create a real VS Code terminal
const terminal = TerminalRegistry.createTerminal().terminal
createdTerminals.push(terminal)
// Spy on emit to verify line events
const emitSpy = sandbox.spy(process, "emit")
// Run a command that produces predictable output
await process.run(terminal, "echo 'Line 1' && echo 'Line 2'")
// Check that the events were emitted
;(emitSpy as sinon.SinonSpy).calledWith("completed").should.be.true()
;(emitSpy as sinon.SinonSpy).calledWith("continue").should.be.true()
})
it("should execute a command that lists files", async () => {
// Create a real VS Code terminal
const terminal = TerminalRegistry.createTerminal().terminal
createdTerminals.push(terminal)
// Spy on emit to verify behavior
const emitSpy = sandbox.spy(process, "emit")
// Run a command that lists files
await process.run(terminal, "ls -la")
// Verify that the continue event was emitted
;(emitSpy as sinon.SinonSpy).calledWith("continue").should.be.true()
;(emitSpy as sinon.SinonSpy).calledWith("completed").should.be.true()
})
it("should handle a longer running command", async () => {
// Create a real terminal
const terminal = TerminalRegistry.createTerminal().terminal
createdTerminals.push(terminal)
// Spy on emit to verify behavior
const emitSpy = sandbox.spy(process, "emit")
// Un-fake timers temporarily for this test since we need real timing
sandbox.clock.restore()
// Run a command that sleeps for a short period
await process.run(terminal, "sleep 0.5 && echo 'Done sleeping'")
// Verify that the continue and completed events were emitted
;(emitSpy as sinon.SinonSpy).calledWith("continue").should.be.true()
;(emitSpy as sinon.SinonSpy).calledWith("completed").should.be.true()
// Restore fake timers for other tests
sandbox.useFakeTimers()
})
it("should execute a command with arguments", async () => {
// Create a real VS Code terminal
const terminal = TerminalRegistry.createTerminal().terminal
createdTerminals.push(terminal)
// Spy on emit to verify line events
const emitSpy = sandbox.spy(process, "emit")
// Run a command that produces predictable output
await process.run(terminal, "echo 'Line 1' 'Line 2'")
// Check that the events were emitted
;(emitSpy as sinon.SinonSpy).calledWith("completed").should.be.true()
;(emitSpy as sinon.SinonSpy).calledWith("continue").should.be.true()
})
it("should execute a command with quotes", async () => {
// Create a real VS Code terminal
const terminal = TerminalRegistry.createTerminal().terminal
createdTerminals.push(terminal)
// Spy on emit to verify line events
const emitSpy = sandbox.spy(process, "emit")
// Run a command that produces predictable output
await process.run(terminal, "echo \"Line 1\" && echo 'Line 2'")
// Check that the events were emitted
;(emitSpy as sinon.SinonSpy).calledWith("completed").should.be.true()
;(emitSpy as sinon.SinonSpy).calledWith("continue").should.be.true()
})
})
// Test that specifically checks for no shell integration
it("should handle terminals without shell integration", async () => {
// Create a real terminal without explicitly providing shell integration
const terminal = vscode.window.createTerminal({ name: "Test Terminal" })
createdTerminals.push(terminal)
// Stub the shellIntegration getter to return undefined for this test
sandbox.stub(terminal, "shellIntegration").get(() => undefined)
// Stub the sendText method to verify it's called
const sendTextStub = sandbox.stub(terminal, "sendText")
// Spy on the emit function to verify events
const emitSpy = sandbox.spy(process, "emit")
// Run the command
await process.run(terminal, "test-command")
// Check that the correct methods were called and events emitted
sendTextStub.calledWith("test-command", true).should.be.true()
;(emitSpy as sinon.SinonSpy).calledWith("completed").should.be.true()
;(emitSpy as sinon.SinonSpy).calledWith("continue").should.be.true()
// This event should be emitted for terminals without shell integration
;(emitSpy as sinon.SinonSpy).calledWith("no_shell_integration").should.be.true()
})
// The following tests require shell integration and controlled terminal output
describe("Shell integration tests", () => {
// We'll mock the terminal run process and TerminalProcess for these tests
it("should emit completed and continue events when command finishes", async function () {
// Create a terminal to ensure proper interface, but we'll use mocking under the hood
const terminal = TerminalRegistry.createTerminal().terminal
createdTerminals.push(terminal)
// Create a mock implementation of executeCommand
const mockExecuteCommand = sandbox.stub().returns({
read: () => createMockStream(["echo test", "test output"]),
})
// Create a fake shell integration object
const mockShellIntegration = {
executeCommand: mockExecuteCommand,
}
// Stub terminal.shellIntegration to return our mock
sandbox.stub(terminal, "shellIntegration").get(() => mockShellIntegration)
// Spy on emit to verify behavior
const emitSpy = sandbox.spy(process, "emit")
// Run the command
await process.run(terminal, "echo test")
// Verify the executeCommand was called with the right command
mockExecuteCommand.calledWith("echo test").should.be.true()
// Check that the events were emitted
;(emitSpy as sinon.SinonSpy).calledWith("completed").should.be.true()
;(emitSpy as sinon.SinonSpy).calledWith("continue").should.be.true()
})
})
// Tests with controlled output
describe("Controlled output tests", () => {
it("should emit line events for each line of output", async function () {
// Create a terminal
const terminal = TerminalRegistry.createTerminal().terminal
createdTerminals.push(terminal)
// Mock the shell integration with controlled output
const mockExecuteCommand = sandbox.stub().returns({
read: () => createMockStream(["test-command", "line1", "line2", "line3"]),
})
// Create a mock shell integration object and stub the getter
sandbox.stub(terminal, "shellIntegration").get(() => ({
executeCommand: mockExecuteCommand,
}))
const emitSpy = sandbox.spy(process, "emit")
await process.run(terminal, "test-command")
// Check that line events were emitted for each line
;(emitSpy as sinon.SinonSpy).calledWith("line", "line1").should.be.true()
;(emitSpy as sinon.SinonSpy).calledWith("line", "line2").should.be.true()
;(emitSpy as sinon.SinonSpy).calledWith("line", "line3").should.be.true()
})
it("should properly handle process hot state (e.g. compiling)", async function () {
// Create a terminal
const terminal = TerminalRegistry.createTerminal().terminal
createdTerminals.push(terminal)
// Mock the shell integration
const mockExecuteCommand = sandbox.stub().returns({
read: () => createMockStream(["compiling..."]),
})
// Create a mock shell integration object and stub the getter
sandbox.stub(terminal, "shellIntegration").get(() => ({
executeCommand: mockExecuteCommand,
}))
// Spy on global setTimeout
const setTimeoutSpy = sandbox.spy(global, "setTimeout")
await process.run(terminal, "build command")
// Move time forward enough to schedule
sandbox.clock.tick(100)
// Expect a 15-second (>= 10000ms) hot timeout, since it saw "compiling"
const foundCompilingTimeout = setTimeoutSpy.args.filter((args) => args[1] && args[1] >= 10000)
foundCompilingTimeout.length.should.be.greaterThan(0)
})
it("should handle standard commands with normal hot timeout", async function () {
// Create a terminal
const terminal = TerminalRegistry.createTerminal().terminal
createdTerminals.push(terminal)
// Mock the shell integration
const mockExecuteCommand = sandbox.stub().returns({
read: () => createMockStream(["some normal output"]),
})
// Create a mock shell integration object and stub the getter
sandbox.stub(terminal, "shellIntegration").get(() => ({
executeCommand: mockExecuteCommand,
}))
const setTimeoutSpy = sandbox.spy(global, "setTimeout")
await process.run(terminal, "standard command")
sandbox.clock.tick(100)
// Expect a short hot timeout (<= 5000)
const foundNormalTimeout = setTimeoutSpy.args.filter((args) => args[1] && args[1] <= 5000)
foundNormalTimeout.length.should.be.greaterThan(0)
// Also check that "completed" eventually emits
const emitSpy = sandbox.spy(process, "emit")
await process.run(terminal, "another command")
;(emitSpy as sinon.SinonSpy).calledWith("completed").should.be.true()
})
it("should correctly filter command echoes based on current implementation", async function () {
// Create a terminal
const terminal = TerminalRegistry.createTerminal().terminal
createdTerminals.push(terminal)
// Mock the shell integration
const mockExecuteCommand = sandbox.stub().returns({
read: () =>
createMockStream([
"test-command", // This should be filtered (command contains this exactly)
"test command", // This should NOT be filtered (doesn't match exactly)
"other output",
]),
})
// Create a mock shell integration object and stub the getter
sandbox.stub(terminal, "shellIntegration").get(() => ({
executeCommand: mockExecuteCommand,
}))
const emitSpy = sandbox.spy(process, "emit")
await process.run(terminal, "test-command")
// Check that "test-command" was filtered out but "test command" was not
;(emitSpy as sinon.SinonSpy).calledWith("line", "test command").should.be.true()
;(emitSpy as sinon.SinonSpy).calledWith("line", "other output").should.be.true()
// This should never be called because it should be filtered
;(emitSpy as sinon.SinonSpy).calledWith("line", "test-command").should.be.false()
})
it("should handle npm run commands", async function () {
// Create a terminal
const terminal = TerminalRegistry.createTerminal().terminal
createdTerminals.push(terminal)
// Mock the shell integration
const mockExecuteCommand = sandbox.stub().returns({
read: () => createMockStream(["npm run build", "> project@1.0.0 build", "> tsc", "files built successfully"]),
})
// Create a mock shell integration object and stub the getter
sandbox.stub(terminal, "shellIntegration").get(() => ({
executeCommand: mockExecuteCommand,
}))
const emitSpy = sandbox.spy(process, "emit")
await process.run(terminal, "npm run build")
// The "npm run build" line should be filtered, but the rest should be emitted
;(emitSpy as sinon.SinonSpy).calledWith("line", "> project@1.0.0 build").should.be.true()
;(emitSpy as sinon.SinonSpy).calledWith("line", "> tsc").should.be.true()
;(emitSpy as sinon.SinonSpy).calledWith("line", "files built successfully").should.be.true()
})
})
// The following tests are shared with the unit tests to ensure consistent behavior
it("should emit line for remaining buffer when emitRemainingBufferIfListening is called", () => {
// Access private properties via type assertion
const processAny = process as any
processAny.buffer = "test buffer content"
processAny.isListening = true
const emitSpy = sandbox.spy(process, "emit")
processAny.emitRemainingBufferIfListening()
;(emitSpy as sinon.SinonSpy).calledWith("line", "test buffer content").should.be.true()
processAny.buffer.should.equal("")
})
it("should remove prompt characters from the last line of output", () => {
const processAny = process as any
processAny.removeLastLineArtifacts("line 1\nline 2 %").should.equal("line 1\nline 2")
processAny.removeLastLineArtifacts("line 1\nline 2 $").should.equal("line 1\nline 2")
processAny.removeLastLineArtifacts("line 1\nline 2 #").should.equal("line 1\nline 2")
processAny.removeLastLineArtifacts("line 1\nline 2 >").should.equal("line 1\nline 2")
})
it("should process buffer and emit lines when newline characters are found", () => {
const processAny = process as any
const emitSpy = sandbox.spy(process, "emit")
processAny.emitIfEol("line 1\nline 2\nline 3")
;(emitSpy as sinon.SinonSpy).calledWith("line", "line 1").should.be.true()
;(emitSpy as sinon.SinonSpy).calledWith("line", "line 2").should.be.true()
processAny.buffer.should.equal("line 3")
processAny.emitIfEol(" continued\n")
;(emitSpy as sinon.SinonSpy).calledWith("line", "line 3 continued").should.be.true()
processAny.buffer.should.equal("")
})
})
+1 -1
View File
@@ -1,5 +1,5 @@
import { EventEmitter } from "events"
import { stripAnsi } from "./ansiUtils"
import stripAnsi from "strip-ansi"
import * as vscode from "vscode"
export interface TerminalProcessEvents {
-14
View File
@@ -1,14 +0,0 @@
export function ansiRegex({ onlyFirst = false } = {}) {
// Valid string terminator sequences are BEL, ESC\, and 0x9c
const ST = "(?:\\u0007|\\u001B\\u005C|\\u009C)"
const pattern = [
`[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?${ST})`,
"(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))",
].join("|")
return new RegExp(pattern, onlyFirst ? undefined : "g")
}
export function stripAnsi(string: string): string {
return string.replace(ansiRegex(), "")
}
+37 -117
View File
@@ -1,127 +1,47 @@
import * as vscode from "vscode"
import * as path from "path"
import { listFiles } from "../../services/glob/list-files"
import { ClineProvider } from "../../core/webview/ClineProvider"
import { IClineProvider } from "../../core/webview/IClineProvider"
import { ExtensionMessage } from "../../shared/ExtensionMessage"
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
// Note: this is not a drop-in replacement for listFiles at the start of tasks, since that will be done for Desktops when there is no workspace selected
class WorkspaceTracker {
private providerRef: WeakRef<ClineProvider>
export default class WorkspaceTracker {
private disposables: vscode.Disposable[] = []
private filePaths: Set<string> = new Set()
private fileWatcher?: vscode.FileSystemWatcher
constructor(provider: ClineProvider) {
this.providerRef = new WeakRef(provider)
this.registerListeners()
constructor(private provider: IClineProvider) {
this.setupFileWatcher()
}
dispose() {
if (this.fileWatcher) {
this.fileWatcher.dispose()
}
while (this.disposables.length) {
const x = this.disposables.pop()
if (x) {
x.dispose()
}
}
}
private setupFileWatcher() {
const workspaceFolders = vscode.workspace.workspaceFolders
if (workspaceFolders) {
this.fileWatcher = vscode.workspace.createFileSystemWatcher("**/*")
this.fileWatcher.onDidChange(() => this.populateFilePaths())
this.fileWatcher.onDidCreate(() => this.populateFilePaths())
this.fileWatcher.onDidDelete(() => this.populateFilePaths())
this.disposables.push(this.fileWatcher)
}
}
async populateFilePaths() {
// should not auto get filepaths for desktop since it would immediately show permission popup before cline ever creates a file
if (!cwd) {
return
const workspaceFolders = vscode.workspace.workspaceFolders
if (workspaceFolders) {
const message: ExtensionMessage = {
type: "workspaceUpdated",
workspace: vscode.workspace.name || "",
workspaceFolders: workspaceFolders.map((folder) => folder.uri.fsPath),
}
await this.provider.postMessageToWebview(message)
}
const [files, _] = await listFiles(cwd, true, 1_000)
files.forEach((file) => this.filePaths.add(this.normalizeFilePath(file)))
this.workspaceDidUpdate()
}
private registerListeners() {
// Listen for file creation
// .bind(this) ensures the callback refers to class instance when using this, not necessary when using arrow function
this.disposables.push(vscode.workspace.onDidCreateFiles(this.onFilesCreated.bind(this)))
// Listen for file deletion
this.disposables.push(vscode.workspace.onDidDeleteFiles(this.onFilesDeleted.bind(this)))
// Listen for file renaming
this.disposables.push(vscode.workspace.onDidRenameFiles(this.onFilesRenamed.bind(this)))
/*
An event that is emitted when a workspace folder is added or removed.
**Note:** this event will not fire if the first workspace folder is added, removed or changed,
because in that case the currently executing extensions (including the one that listens to this
event) will be terminated and restarted so that the (deprecated) `rootPath` property is updated
to point to the first workspace folder.
*/
// In other words, we don't have to worry about the root workspace folder ([0]) changing since the extension will be restarted and our cwd will be updated to reflect the new workspace folder. (We don't care about non root workspace folders, since cline will only be working within the root folder cwd)
// this.disposables.push(vscode.workspace.onDidChangeWorkspaceFolders(this.onWorkspaceFoldersChanged.bind(this)))
}
private async onFilesCreated(event: vscode.FileCreateEvent) {
await Promise.all(
event.files.map(async (file) => {
await this.addFilePath(file.fsPath)
}),
)
this.workspaceDidUpdate()
}
private async onFilesDeleted(event: vscode.FileDeleteEvent) {
let updated = false
await Promise.all(
event.files.map(async (file) => {
if (await this.removeFilePath(file.fsPath)) {
updated = true
}
}),
)
if (updated) {
this.workspaceDidUpdate()
}
}
private async onFilesRenamed(event: vscode.FileRenameEvent) {
await Promise.all(
event.files.map(async (file) => {
await this.removeFilePath(file.oldUri.fsPath)
await this.addFilePath(file.newUri.fsPath)
}),
)
this.workspaceDidUpdate()
}
private workspaceDidUpdate() {
if (!cwd) {
return
}
this.providerRef.deref()?.postMessageToWebview({
type: "workspaceUpdated",
filePaths: Array.from(this.filePaths).map((file) => {
const relativePath = path.relative(cwd, file).toPosix()
return file.endsWith("/") ? relativePath + "/" : relativePath
}),
})
}
private normalizeFilePath(filePath: string): string {
const resolvedPath = cwd ? path.resolve(cwd, filePath) : path.resolve(filePath)
return filePath.endsWith("/") ? resolvedPath + "/" : resolvedPath
}
private async addFilePath(filePath: string): Promise<string> {
const normalizedPath = this.normalizeFilePath(filePath)
try {
const stat = await vscode.workspace.fs.stat(vscode.Uri.file(normalizedPath))
const isDirectory = (stat.type & vscode.FileType.Directory) !== 0
const pathWithSlash = isDirectory && !normalizedPath.endsWith("/") ? normalizedPath + "/" : normalizedPath
this.filePaths.add(pathWithSlash)
return pathWithSlash
} catch {
// If stat fails, assume it's a file (this can happen for newly created files)
this.filePaths.add(normalizedPath)
return normalizedPath
}
}
private async removeFilePath(filePath: string): Promise<boolean> {
const normalizedPath = this.normalizeFilePath(filePath)
return this.filePaths.delete(normalizedPath) || this.filePaths.delete(normalizedPath + "/")
}
public dispose() {
this.disposables.forEach((d) => d.dispose())
}
}
export default WorkspaceTracker
+31
View File
@@ -0,0 +1,31 @@
import * as vscode from "vscode"
import { IClineProvider } from "../../core/webview/IClineProvider"
export interface UserInfo {
displayName: string | null
email: string | null
photoURL: string | null
}
export class FirebaseAuthManager {
constructor(private provider: IClineProvider) {}
dispose() {
// Implementation
}
async signOut() {
await this.provider.setAuthToken(undefined)
await this.provider.setUserInfo(undefined)
}
async signInWithCustomToken(token: string) {
await this.provider.setAuthToken(token)
// Implementation for getting user info would go here
await this.provider.setUserInfo({
displayName: null,
email: null,
photoURL: null,
})
}
}
+1 -2
View File
@@ -36,7 +36,7 @@ export async function listFiles(dirPath: string, recursive: boolean, limit: numb
".*", // '!**/.*' excludes hidden directories, while '!**/.*/**' excludes only their contents. This way we are at least aware of the existence of hidden directories.
].map((dir) => `**/${dir}/**`)
const options: Options = {
const options = {
cwd: dirPath,
dot: true, // do not ignore hidden files/directories
absolute: true,
@@ -44,7 +44,6 @@ export async function listFiles(dirPath: string, recursive: boolean, limit: numb
gitignore: recursive, // globby ignores any files that are gitignored
ignore: recursive ? dirsToIgnore : undefined, // just in case there is no gitignore, we ignore sensible defaults
onlyFiles: false, // true by default, false means it will list directories on their own too
suppressErrors: true,
}
// * globs all files in one dir, ** globs files in nested directories
+72 -692
View File
@@ -1,706 +1,86 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { StdioClientTransport, StdioServerParameters } from "@modelcontextprotocol/sdk/client/stdio.js"
import {
CallToolResultSchema,
ListResourcesResultSchema,
ListResourceTemplatesResultSchema,
ListToolsResultSchema,
ReadResourceResultSchema,
} from "@modelcontextprotocol/sdk/types.js"
import chokidar, { FSWatcher } from "chokidar"
import delay from "delay"
import deepEqual from "fast-deep-equal"
import * as fs from "fs/promises"
import * as path from "path"
import * as vscode from "vscode"
import { z } from "zod"
import { ClineProvider, GlobalFileNames } from "../../core/webview/ClineProvider"
import {
DEFAULT_MCP_TIMEOUT_SECONDS,
McpMode,
McpResource,
McpResourceResponse,
McpResourceTemplate,
McpServer,
McpTool,
McpToolCallResponse,
MIN_MCP_TIMEOUT_SECONDS,
} from "../../shared/mcp"
import { fileExistsAtPath } from "../../utils/fs"
import { arePathsEqual } from "../../utils/path"
import { secondsToMs } from "../../utils/time"
export type McpConnection = {
server: McpServer
client: Client
transport: StdioClientTransport
import { ModelInfo } from "../../shared/api"
import { IClineProvider } from "../../core/webview/IClineProvider"
export interface McpServer {
name: string
tools: McpTool[]
resources: McpResource[]
resourceTemplates: McpResourceTemplate[]
}
const AutoApproveSchema = z.array(z.string()).default([])
export interface McpTool {
name: string
description: string
inputSchema: any
autoApprove: boolean
}
const StdioConfigSchema = z.object({
command: z.string(),
args: z.array(z.string()).optional(),
env: z.record(z.string()).optional(),
autoApprove: AutoApproveSchema.optional(),
disabled: z.boolean().optional(),
timeout: z.number().min(MIN_MCP_TIMEOUT_SECONDS).optional().default(DEFAULT_MCP_TIMEOUT_SECONDS),
})
export interface McpResource {
uri: string
name: string
description?: string
mimeType?: string
}
const McpSettingsSchema = z.object({
mcpServers: z.record(StdioConfigSchema),
})
export interface McpResourceTemplate {
uriTemplate: string
name: string
description?: string
mimeType?: string
}
export interface McpConnection {
server: McpServer
callTool: (toolName: string, args: any) => Promise<any>
readResource: (uri: string) => Promise<any>
}
export class McpHub {
private providerRef: WeakRef<ClineProvider>
private disposables: vscode.Disposable[] = []
private settingsWatcher?: vscode.FileSystemWatcher
private fileWatchers: Map<string, FSWatcher> = new Map()
connections: McpConnection[] = []
isConnecting: boolean = false
private connections: McpConnection[] = []
private isConnecting: boolean = false
private mode: "off" | "limited" | "full" = "off"
constructor(provider: ClineProvider) {
this.providerRef = new WeakRef(provider)
this.watchMcpSettingsFile()
this.initializeMcpServers()
constructor(private provider: IClineProvider) {
// Initialize MCP hub
}
getServers(): McpServer[] {
// Only return enabled servers
return this.connections.filter((conn) => !conn.server.disabled).map((conn) => conn.server)
}
getMode(): McpMode {
return vscode.workspace.getConfiguration("cline.mcp").get<McpMode>("mode", "full")
}
async getMcpServersPath(): Promise<string> {
const provider = this.providerRef.deref()
if (!provider) {
throw new Error("Provider not available")
}
const mcpServersPath = await provider.ensureMcpServersDirectoryExists()
return mcpServersPath
}
async getMcpSettingsFilePath(): Promise<string> {
const provider = this.providerRef.deref()
if (!provider) {
throw new Error("Provider not available")
}
const mcpSettingsFilePath = path.join(await provider.ensureSettingsDirectoryExists(), GlobalFileNames.mcpSettings)
const fileExists = await fileExistsAtPath(mcpSettingsFilePath)
if (!fileExists) {
await fs.writeFile(
mcpSettingsFilePath,
`{
"mcpServers": {
}
}`,
)
}
return mcpSettingsFilePath
}
private async watchMcpSettingsFile(): Promise<void> {
const settingsPath = await this.getMcpSettingsFilePath()
this.disposables.push(
vscode.workspace.onDidSaveTextDocument(async (document) => {
if (arePathsEqual(document.uri.fsPath, settingsPath)) {
const content = await fs.readFile(settingsPath, "utf-8")
const errorMessage =
"Invalid MCP settings format. Please ensure your settings follow the correct JSON format."
let config: any
try {
config = JSON.parse(content)
} catch (error) {
vscode.window.showErrorMessage(errorMessage)
return
}
const result = McpSettingsSchema.safeParse(config)
if (!result.success) {
vscode.window.showErrorMessage(errorMessage)
return
}
try {
vscode.window.showInformationMessage("Updating MCP servers...")
await this.updateServerConnections(result.data.mcpServers || {})
vscode.window.showInformationMessage("MCP servers updated")
} catch (error) {
console.error("Failed to process MCP settings change:", error)
}
}
}),
)
}
private async initializeMcpServers(): Promise<void> {
try {
const settingsPath = await this.getMcpSettingsFilePath()
const content = await fs.readFile(settingsPath, "utf-8")
const config = JSON.parse(content)
await this.updateServerConnections(config.mcpServers || {})
} catch (error) {
console.error("Failed to initialize MCP servers:", error)
}
}
private async connectToServer(name: string, config: StdioServerParameters): Promise<void> {
// Remove existing connection if it exists (should never happen, the connection should be deleted beforehand)
this.connections = this.connections.filter((conn) => conn.server.name !== name)
try {
// Each MCP server requires its own transport connection and has unique capabilities, configurations, and error handling. Having separate clients also allows proper scoping of resources/tools and independent server management like reconnection.
const client = new Client(
{
name: "Cline",
version: this.providerRef.deref()?.context.extension?.packageJSON?.version ?? "1.0.0",
},
{
capabilities: {},
},
)
const transport = new StdioClientTransport({
command: config.command,
args: config.args,
env: {
...config.env,
...(process.env.PATH ? { PATH: process.env.PATH } : {}),
// ...(process.env.NODE_PATH ? { NODE_PATH: process.env.NODE_PATH } : {}),
},
stderr: "pipe", // necessary for stderr to be available
})
transport.onerror = async (error) => {
console.error(`Transport error for "${name}":`, error)
const connection = this.connections.find((conn) => conn.server.name === name)
if (connection) {
connection.server.status = "disconnected"
this.appendErrorMessage(connection, error.message)
}
await this.notifyWebviewOfServerChanges()
}
transport.onclose = async () => {
const connection = this.connections.find((conn) => conn.server.name === name)
if (connection) {
connection.server.status = "disconnected"
}
await this.notifyWebviewOfServerChanges()
}
// If the config is invalid, show an error
if (!StdioConfigSchema.safeParse(config).success) {
console.error(`Invalid config for "${name}": missing or invalid parameters`)
const connection: McpConnection = {
server: {
name,
config: JSON.stringify(config),
status: "disconnected",
error: "Invalid config: missing or invalid parameters",
},
client,
transport,
}
this.connections.push(connection)
return
}
// valid schema
const parsedConfig = StdioConfigSchema.parse(config)
const connection: McpConnection = {
server: {
name,
config: JSON.stringify(config),
status: "connecting",
disabled: parsedConfig.disabled,
},
client,
transport,
}
this.connections.push(connection)
// transport.stderr is only available after the process has been started. However we can't start it separately from the .connect() call because it also starts the transport. And we can't place this after the connect call since we need to capture the stderr stream before the connection is established, in order to capture errors during the connection process.
// As a workaround, we start the transport ourselves, and then monkey-patch the start method to no-op so that .connect() doesn't try to start it again.
await transport.start()
const stderrStream = transport.stderr
if (stderrStream) {
stderrStream.on("data", async (data: Buffer) => {
const errorOutput = data.toString()
console.error(`Server "${name}" stderr:`, errorOutput)
const connection = this.connections.find((conn) => conn.server.name === name)
if (connection) {
// NOTE: we do not set server status to "disconnected" because stderr logs do not necessarily mean the server crashed or disconnected, it could just be informational. In fact when the server first starts up, it immediately logs "<name> server running on stdio" to stderr.
this.appendErrorMessage(connection, errorOutput)
// Only need to update webview right away if it's already disconnected
if (connection.server.status === "disconnected") {
await this.notifyWebviewOfServerChanges()
}
}
})
} else {
console.error(`No stderr stream for ${name}`)
}
transport.start = async () => {} // No-op now, .connect() won't fail
// Connect
await client.connect(transport)
connection.server.status = "connected"
connection.server.error = ""
// Initial fetch of tools and resources
connection.server.tools = await this.fetchToolsList(name)
connection.server.resources = await this.fetchResourcesList(name)
connection.server.resourceTemplates = await this.fetchResourceTemplatesList(name)
} catch (error) {
// Update status with error
const connection = this.connections.find((conn) => conn.server.name === name)
if (connection) {
connection.server.status = "disconnected"
this.appendErrorMessage(connection, error instanceof Error ? error.message : String(error))
}
throw error
}
}
private appendErrorMessage(connection: McpConnection, error: string) {
const newError = connection.server.error ? `${connection.server.error}\n${error}` : error
connection.server.error = newError //.slice(0, 800)
}
private async fetchToolsList(serverName: string): Promise<McpTool[]> {
try {
const response = await this.connections
.find((conn) => conn.server.name === serverName)
?.client.request({ method: "tools/list" }, ListToolsResultSchema)
// Get autoApprove settings
const settingsPath = await this.getMcpSettingsFilePath()
const content = await fs.readFile(settingsPath, "utf-8")
const config = JSON.parse(content)
const autoApproveConfig = config.mcpServers[serverName]?.autoApprove || []
// Mark tools as always allowed based on settings
const tools = (response?.tools || []).map((tool) => ({
...tool,
autoApprove: autoApproveConfig.includes(tool.name),
}))
// console.log(`[MCP] Fetched tools for ${serverName}:`, tools)
return tools
} catch (error) {
// console.error(`Failed to fetch tools for ${serverName}:`, error)
return []
}
}
private async fetchResourcesList(serverName: string): Promise<McpResource[]> {
try {
const response = await this.connections
.find((conn) => conn.server.name === serverName)
?.client.request({ method: "resources/list" }, ListResourcesResultSchema)
return response?.resources || []
} catch (error) {
// console.error(`Failed to fetch resources for ${serverName}:`, error)
return []
}
}
private async fetchResourceTemplatesList(serverName: string): Promise<McpResourceTemplate[]> {
try {
const response = await this.connections
.find((conn) => conn.server.name === serverName)
?.client.request({ method: "resources/templates/list" }, ListResourceTemplatesResultSchema)
return response?.resourceTemplates || []
} catch (error) {
// console.error(`Failed to fetch resource templates for ${serverName}:`, error)
return []
}
}
async deleteConnection(name: string): Promise<void> {
const connection = this.connections.find((conn) => conn.server.name === name)
if (connection) {
try {
await connection.transport.close()
await connection.client.close()
} catch (error) {
console.error(`Failed to close transport for ${name}:`, error)
}
this.connections = this.connections.filter((conn) => conn.server.name !== name)
}
}
async updateServerConnections(newServers: Record<string, any>): Promise<void> {
this.isConnecting = true
this.removeAllFileWatchers()
const currentNames = new Set(this.connections.map((conn) => conn.server.name))
const newNames = new Set(Object.keys(newServers))
// Delete removed servers
for (const name of currentNames) {
if (!newNames.has(name)) {
await this.deleteConnection(name)
console.log(`Deleted MCP server: ${name}`)
}
}
// Update or add servers
for (const [name, config] of Object.entries(newServers)) {
const currentConnection = this.connections.find((conn) => conn.server.name === name)
if (!currentConnection) {
// New server
try {
this.setupFileWatcher(name, config)
await this.connectToServer(name, config)
} catch (error) {
console.error(`Failed to connect to new MCP server ${name}:`, error)
}
} else if (!deepEqual(JSON.parse(currentConnection.server.config), config)) {
// Existing server with changed config
try {
this.setupFileWatcher(name, config)
await this.deleteConnection(name)
await this.connectToServer(name, config)
console.log(`Reconnected MCP server with updated config: ${name}`)
} catch (error) {
console.error(`Failed to reconnect MCP server ${name}:`, error)
}
}
// If server exists with same config, do nothing
}
await this.notifyWebviewOfServerChanges()
this.isConnecting = false
}
private setupFileWatcher(name: string, config: any) {
const filePath = config.args?.find((arg: string) => arg.includes("build/index.js"))
if (filePath) {
// we use chokidar instead of onDidSaveTextDocument because it doesn't require the file to be open in the editor. The settings config is better suited for onDidSave since that will be manually updated by the user or Cline (and we want to detect save events, not every file change)
const watcher = chokidar.watch(filePath, {
// persistent: true,
// ignoreInitial: true,
// awaitWriteFinish: true, // This helps with atomic writes
})
watcher.on("change", () => {
console.log(`Detected change in ${filePath}. Restarting server ${name}...`)
this.restartConnection(name)
})
this.fileWatchers.set(name, watcher)
}
}
private removeAllFileWatchers() {
this.fileWatchers.forEach((watcher) => watcher.close())
this.fileWatchers.clear()
}
async restartConnection(serverName: string): Promise<void> {
this.isConnecting = true
const provider = this.providerRef.deref()
if (!provider) {
return
}
// Get existing connection and update its status
const connection = this.connections.find((conn) => conn.server.name === serverName)
const config = connection?.server.config
if (config) {
vscode.window.showInformationMessage(`Restarting ${serverName} MCP server...`)
connection.server.status = "connecting"
connection.server.error = ""
await this.notifyWebviewOfServerChanges()
await delay(500) // artificial delay to show user that server is restarting
try {
await this.deleteConnection(serverName)
// Try to connect again using existing config
await this.connectToServer(serverName, JSON.parse(config))
vscode.window.showInformationMessage(`${serverName} MCP server connected`)
} catch (error) {
console.error(`Failed to restart connection for ${serverName}:`, error)
vscode.window.showErrorMessage(`Failed to connect to ${serverName} MCP server`)
}
}
await this.notifyWebviewOfServerChanges()
this.isConnecting = false
}
private async notifyWebviewOfServerChanges(): Promise<void> {
// servers should always be sorted in the order they are defined in the settings file
const settingsPath = await this.getMcpSettingsFilePath()
const content = await fs.readFile(settingsPath, "utf-8")
const config = JSON.parse(content)
const serverOrder = Object.keys(config.mcpServers || {})
await this.providerRef.deref()?.postMessageToWebview({
type: "mcpServers",
mcpServers: [...this.connections]
.sort((a, b) => {
const indexA = serverOrder.indexOf(a.server.name)
const indexB = serverOrder.indexOf(b.server.name)
return indexA - indexB
})
.map((connection) => connection.server),
})
}
async sendLatestMcpServers() {
await this.notifyWebviewOfServerChanges()
}
// Using server
// Public methods for server management
public async toggleServerDisabled(serverName: string, disabled: boolean): Promise<void> {
let settingsPath: string
try {
settingsPath = await this.getMcpSettingsFilePath()
// Ensure the settings file exists and is accessible
try {
await fs.access(settingsPath)
} catch (error) {
console.error("Settings file not accessible:", error)
throw new Error("Settings file not accessible")
}
const content = await fs.readFile(settingsPath, "utf-8")
const config = JSON.parse(content)
// Validate the config structure
if (!config || typeof config !== "object") {
throw new Error("Invalid config structure")
}
if (!config.mcpServers || typeof config.mcpServers !== "object") {
config.mcpServers = {}
}
if (config.mcpServers[serverName]) {
// Create a new server config object to ensure clean structure
const serverConfig = {
...config.mcpServers[serverName],
disabled,
}
// Ensure required fields exist
if (!serverConfig.autoApprove) {
serverConfig.autoApprove = []
}
config.mcpServers[serverName] = serverConfig
// Write the entire config back
const updatedConfig = {
mcpServers: config.mcpServers,
}
await fs.writeFile(settingsPath, JSON.stringify(updatedConfig, null, 2))
const connection = this.connections.find((conn) => conn.server.name === serverName)
if (connection) {
try {
connection.server.disabled = disabled
// Only refresh capabilities if connected
if (connection.server.status === "connected") {
connection.server.tools = await this.fetchToolsList(serverName)
connection.server.resources = await this.fetchResourcesList(serverName)
connection.server.resourceTemplates = await this.fetchResourceTemplatesList(serverName)
}
} catch (error) {
console.error(`Failed to refresh capabilities for ${serverName}:`, error)
}
}
await this.notifyWebviewOfServerChanges()
}
} catch (error) {
console.error("Failed to update server disabled state:", error)
if (error instanceof Error) {
console.error("Error details:", error.message, error.stack)
}
vscode.window.showErrorMessage(
`Failed to update server state: ${error instanceof Error ? error.message : String(error)}`,
)
throw error
}
}
async readResource(serverName: string, uri: string): Promise<McpResourceResponse> {
const connection = this.connections.find((conn) => conn.server.name === serverName)
if (!connection) {
throw new Error(`No connection found for server: ${serverName}`)
}
if (connection.server.disabled) {
throw new Error(`Server "${serverName}" is disabled`)
}
return await connection.client.request(
{
method: "resources/read",
params: {
uri,
},
},
ReadResourceResultSchema,
)
}
async callTool(serverName: string, toolName: string, toolArguments?: Record<string, unknown>): Promise<McpToolCallResponse> {
const connection = this.connections.find((conn) => conn.server.name === serverName)
if (!connection) {
throw new Error(
`No connection found for server: ${serverName}. Please make sure to use MCP servers available under 'Connected MCP Servers'.`,
)
}
if (connection.server.disabled) {
throw new Error(`Server "${serverName}" is disabled and cannot be used`)
}
let timeout = secondsToMs(DEFAULT_MCP_TIMEOUT_SECONDS) // sdk expects ms
try {
const config = JSON.parse(connection.server.config)
const parsedConfig = StdioConfigSchema.parse(config)
timeout = secondsToMs(parsedConfig.timeout)
} catch (error) {
console.error(`Failed to parse timeout configuration for server ${serverName}: ${error}`)
}
return await connection.client.request(
{
method: "tools/call",
params: {
name: toolName,
arguments: toolArguments,
},
},
CallToolResultSchema,
{
timeout,
},
)
}
async toggleToolAutoApprove(serverName: string, toolName: string, shouldAllow: boolean): Promise<void> {
try {
const settingsPath = await this.getMcpSettingsFilePath()
const content = await fs.readFile(settingsPath, "utf-8")
const config = JSON.parse(content)
// Initialize autoApprove if it doesn't exist
if (!config.mcpServers[serverName].autoApprove) {
config.mcpServers[serverName].autoApprove = []
}
const autoApprove = config.mcpServers[serverName].autoApprove
const toolIndex = autoApprove.indexOf(toolName)
if (shouldAllow && toolIndex === -1) {
// Add tool to autoApprove list
autoApprove.push(toolName)
} else if (!shouldAllow && toolIndex !== -1) {
// Remove tool from autoApprove list
autoApprove.splice(toolIndex, 1)
}
await fs.writeFile(settingsPath, JSON.stringify(config, null, 2))
// Update the tools list to reflect the change
const connection = this.connections.find((conn) => conn.server.name === serverName)
if (connection) {
connection.server.tools = await this.fetchToolsList(serverName)
await this.notifyWebviewOfServerChanges()
}
} catch (error) {
console.error("Failed to update autoApprove settings:", error)
vscode.window.showErrorMessage("Failed to update autoApprove settings")
throw error // Re-throw to ensure the error is properly handled
}
}
public async deleteServer(serverName: string) {
try {
const settingsPath = await this.getMcpSettingsFilePath()
const content = await fs.readFile(settingsPath, "utf-8")
const config = JSON.parse(content)
if (!config.mcpServers || typeof config.mcpServers !== "object") {
config.mcpServers = {}
}
if (config.mcpServers[serverName]) {
delete config.mcpServers[serverName]
const updatedConfig = {
mcpServers: config.mcpServers,
}
await fs.writeFile(settingsPath, JSON.stringify(updatedConfig, null, 2))
await this.updateServerConnections(config.mcpServers)
vscode.window.showInformationMessage(`Deleted ${serverName} MCP server`)
} else {
vscode.window.showWarningMessage(`${serverName} not found in MCP configuration`)
}
} catch (error) {
vscode.window.showErrorMessage(
`Failed to delete MCP server: ${error instanceof Error ? error.message : String(error)}`,
)
throw error
}
}
public async updateServerTimeout(serverName: string, timeout: number): Promise<void> {
try {
// Validate timeout against schema
const setConfigResult = StdioConfigSchema.shape.timeout.safeParse(timeout)
if (!setConfigResult.success) {
throw new Error(`Invalid timeout value: ${timeout}. Must be at minimum ${MIN_MCP_TIMEOUT_SECONDS} seconds.`)
}
const settingsPath = await this.getMcpSettingsFilePath()
const content = await fs.readFile(settingsPath, "utf-8")
const config = JSON.parse(content)
if (!config.mcpServers?.[serverName]) {
throw new Error(`Server "${serverName}" not found in settings`)
}
config.mcpServers[serverName] = {
...config.mcpServers[serverName],
timeout,
}
await fs.writeFile(settingsPath, JSON.stringify(config, null, 2))
await this.updateServerConnections(config.mcpServers)
} catch (error) {
console.error("Failed to update server timeout:", error)
if (error instanceof Error) {
console.error("Error details:", error.message, error.stack)
}
vscode.window.showErrorMessage(
`Failed to update server timeout: ${error instanceof Error ? error.message : String(error)}`,
)
throw error
}
}
async dispose(): Promise<void> {
this.removeAllFileWatchers()
for (const connection of this.connections) {
try {
await this.deleteConnection(connection.server.name)
} catch (error) {
console.error(`Failed to close connection for ${connection.server.name}:`, error)
}
}
dispose() {
// Clean up connections
this.connections = []
if (this.settingsWatcher) {
this.settingsWatcher.dispose()
this.isConnecting = false
this.mode = "off"
}
getMode(): "off" | "limited" | "full" {
return this.mode
}
getMcpServersPath(): Promise<string> {
return Promise.resolve("/Users/ocasta/Documents/Cline/MCP")
}
async callTool(serverName: string, toolName: string, args: any): Promise<any> {
const connection = this.connections.find((conn) => conn.server.name === serverName)
if (!connection) {
throw new Error(`Server ${serverName} not found`)
}
this.disposables.forEach((d) => d.dispose())
return connection.callTool(toolName, args)
}
async readResource(serverName: string, uri: string): Promise<any> {
const connection = this.connections.find((conn) => conn.server.name === serverName)
if (!connection) {
throw new Error(`Server ${serverName} not found`)
}
return connection.readResource(uri)
}
getConnections(): McpConnection[] {
return this.connections
}
isConnected(serverName: string): boolean {
return this.connections.some((conn) => conn.server.name === serverName)
}
}
-432
View File
@@ -1,432 +0,0 @@
import { PostHog } from "posthog-node"
import * as vscode from "vscode"
import { version as extensionVersion } from "../../../package.json"
/**
* PostHogClient handles telemetry event tracking for the Cline extension
* Uses PostHog analytics to track user interactions and system events
* Respects user privacy settings and VSCode's global telemetry configuration
*/
class PostHogClient {
// Event constants for tracking user interactions and system events
private static readonly EVENTS = {
// Task-related events for tracking conversation and execution flow
TASK: {
// Tracks when a new task/conversation is started
CREATED: "task.created",
// Tracks when a task is reopened
RESTARTED: "task.restarted",
// Tracks when a task is finished, with acceptance or rejection status
COMPLETED: "task.completed",
// Tracks when a message is sent in a conversation
CONVERSATION_TURN: "task.conversation_turn",
// Tracks token consumption for cost and usage analysis
TOKEN_USAGE: "task.tokens",
// Tracks switches between plan and act modes
MODE_SWITCH: "task.mode",
// Tracks usage of the git-based checkpoint system (shadow_git_initialized, commit_created, branch_created, branch_deleted_active, branch_deleted_inactive, restored)
CHECKPOINT_USED: "task.checkpoint_used",
// Tracks when tools (like file operations, commands) are used
TOOL_USED: "task.tool_used",
// Tracks when a historical task is loaded from storage
HISTORICAL_LOADED: "task.historical_loaded",
// Tracks when the retry button is clicked for failed operations
RETRY_CLICKED: "task.retry_clicked",
},
// UI interaction events for tracking user engagement
UI: {
// Tracks when user switches between API providers
PROVIDER_SWITCH: "ui.provider_switch",
// Tracks when images are attached to a conversation
IMAGE_ATTACHED: "ui.image_attached",
// Tracks general button click interactions
BUTTON_CLICK: "ui.button_click",
// Tracks when the marketplace view is opened
MARKETPLACE_OPENED: "ui.marketplace_opened",
// Tracks when settings panel is opened
SETTINGS_OPENED: "ui.settings_opened",
// Tracks when task history view is opened
HISTORY_OPENED: "ui.history_opened",
// Tracks when a task is removed from history
TASK_POPPED: "ui.task_popped",
// Tracks when a different model is selected
MODEL_SELECTED: "ui.model_selected",
// Tracks when planning mode is toggled on
PLAN_MODE_TOGGLED: "ui.plan_mode_toggled",
// Tracks when action mode is toggled on
ACT_MODE_TOGGLED: "ui.act_mode_toggled",
},
}
/** Singleton instance of the PostHogClient */
private static instance: PostHogClient
/** PostHog client instance for sending analytics events */
private client: PostHog
/** Unique identifier for the current VSCode instance */
private distinctId: string = vscode.env.machineId
/** Whether telemetry is currently enabled based on user and VSCode settings */
private telemetryEnabled: boolean = false
/** Current version of the extension */
private readonly version: string = extensionVersion
/**
* Private constructor to enforce singleton pattern
* Initializes PostHog client with configuration
*/
private constructor() {
this.client = new PostHog("phc_qfOAGxZw2TL5O8p9KYd9ak3bPBFzfjC8fy5L6jNWY7K", {
host: "https://us.i.posthog.com",
enableExceptionAutocapture: false,
})
}
/**
* Updates the telemetry state based on user preferences and VSCode settings
* Only enables telemetry if both VSCode global telemetry is enabled and user has opted in
* @param didUserOptIn Whether the user has explicitly opted into telemetry
*/
public updateTelemetryState(didUserOptIn: boolean): void {
this.telemetryEnabled = false
// First check global telemetry level - telemetry should only be enabled when level is "all"
const telemetryLevel = vscode.workspace.getConfiguration("telemetry").get<string>("telemetryLevel", "all")
const globalTelemetryEnabled = telemetryLevel === "all"
// We only enable telemetry if global vscode telemetry is enabled
if (globalTelemetryEnabled) {
this.telemetryEnabled = didUserOptIn
}
// Update PostHog client state based on telemetry preference
if (this.telemetryEnabled) {
this.client.optIn()
} else {
this.client.optOut()
}
}
/**
* Gets or creates the singleton instance of PostHogClient
* @returns The PostHogClient instance
*/
public static getInstance(): PostHogClient {
if (!PostHogClient.instance) {
PostHogClient.instance = new PostHogClient()
}
return PostHogClient.instance
}
/**
* Captures a telemetry event if telemetry is enabled
* @param event The event to capture with its properties
*/
public capture(event: { event: string; properties?: any }): void {
// Only send events if telemetry is enabled
if (this.telemetryEnabled) {
// Include extension version in all event properties
const propertiesWithVersion = {
...event.properties,
extension_version: this.version,
}
this.client.capture({ distinctId: this.distinctId, event: event.event, properties: propertiesWithVersion })
}
}
// Task events
/**
* Records when a new task/conversation is started
* @param taskId Unique identifier for the new task
*/
public captureTaskCreated(taskId: string, apiProvider?: string) {
this.capture({
event: PostHogClient.EVENTS.TASK.CREATED,
properties: { taskId, apiProvider },
})
}
/**
* Records when a task/conversation is restarted
* @param taskId Unique identifier for the new task
*/
public captureTaskRestarted(taskId: string, apiProvider?: string) {
this.capture({
event: PostHogClient.EVENTS.TASK.RESTARTED,
properties: { taskId, apiProvider },
})
}
/**
* Records when cline calls the task completion_result tool signifying that cline is done with the task
* @param taskId Unique identifier for the task
*/
public captureTaskCompleted(taskId: string) {
this.capture({
event: PostHogClient.EVENTS.TASK.COMPLETED,
properties: { taskId },
})
}
/**
* Captures that a message was sent, and includes the API provider and model used
* @param taskId Unique identifier for the task
* @param provider The API provider (e.g., OpenAI, Anthropic)
* @param model The specific model used (e.g., GPT-4, Claude)
* @param source The source of the message ("user" | "model"). Used to track message patterns and identify when users need to correct the model's responses.
*/
public captureConversationTurnEvent(
taskId: string,
provider: string = "unknown",
model: string = "unknown",
source: "user" | "assistant",
) {
// Ensure required parameters are provided
if (!taskId || !provider || !model || !source) {
console.warn("TelemetryService: Missing required parameters for message capture")
return
}
const properties: Record<string, any> = {
taskId,
provider,
model,
source,
timestamp: new Date().toISOString(), // Add timestamp for message sequencing
}
this.capture({
event: PostHogClient.EVENTS.TASK.CONVERSATION_TURN,
properties,
})
}
/**
* TODO
* Records token usage metrics for cost tracking and usage analysis
* @param taskId Unique identifier for the task
* @param tokensIn Number of input tokens consumed
* @param tokensOut Number of output tokens generated
* @param model The model used for token calculation
*/
public captureTokenUsage(taskId: string, tokensIn: number, tokensOut: number, model: string) {
this.capture({
event: PostHogClient.EVENTS.TASK.TOKEN_USAGE,
properties: {
taskId,
tokensIn,
tokensOut,
model,
},
})
}
/**
* Records when a task switches between plan and act modes
* @param taskId Unique identifier for the task
* @param mode The mode being switched to (plan or act)
*/
public captureModeSwitch(taskId: string, mode: "plan" | "act") {
this.capture({
event: PostHogClient.EVENTS.TASK.MODE_SWITCH,
properties: {
taskId,
mode,
},
})
}
// Tool events
/**
* Records when a tool is used during task execution
* @param taskId Unique identifier for the task
* @param tool Name of the tool being used
* @param autoApproved Whether the tool was auto-approved based on settings
* @param success Whether the tool execution was successful
*/
public captureToolUsage(taskId: string, tool: string, autoApproved: boolean, success: boolean) {
this.capture({
event: PostHogClient.EVENTS.TASK.TOOL_USED,
properties: {
taskId,
tool,
autoApproved,
success,
},
})
}
/**
* Records interactions with the git-based checkpoint system
* @param taskId Unique identifier for the task
* @param action The type of checkpoint action
* @param durationMs Optional duration of the operation in milliseconds
*/
public captureCheckpointUsage(
taskId: string,
action: "shadow_git_initialized" | "commit_created" | "restored" | "diff_generated",
durationMs?: number,
) {
this.capture({
event: PostHogClient.EVENTS.TASK.CHECKPOINT_USED,
properties: {
taskId,
action,
durationMs,
},
})
}
// UI events
/**
* Records when the user switches between different API providers
* @param from Previous provider name
* @param to New provider name
* @param location Where the switch occurred (settings panel or bottom bar)
* @param taskId Optional task identifier if switch occurred during a task
*/
public captureProviderSwitch(from: string, to: string, location: "settings" | "bottom", taskId?: string) {
this.capture({
event: PostHogClient.EVENTS.UI.PROVIDER_SWITCH,
properties: {
from,
to,
location,
taskId,
},
})
}
/**
* Records when images are attached to a conversation
* @param taskId Unique identifier for the task
* @param imageCount Number of images attached
*/
public captureImageAttached(taskId: string, imageCount: number) {
this.capture({
event: PostHogClient.EVENTS.UI.IMAGE_ATTACHED,
properties: {
taskId,
imageCount,
},
})
}
/**
* Records general button click interactions in the UI
* @param button Identifier for the button that was clicked
* @param taskId Optional task identifier if click occurred during a task
*/
public captureButtonClick(button: string, taskId?: string) {
this.capture({
event: PostHogClient.EVENTS.UI.BUTTON_CLICK,
properties: {
button,
taskId,
},
})
}
/**
* Records when the marketplace view is opened
* @param taskId Optional task identifier if marketplace was opened during a task
*/
public captureMarketplaceOpened(taskId?: string) {
this.capture({
event: PostHogClient.EVENTS.UI.MARKETPLACE_OPENED,
properties: {
taskId,
},
})
}
/**
* Records when the settings panel is opened
* @param taskId Optional task identifier if settings were opened during a task
*/
public captureSettingsOpened(taskId?: string) {
this.capture({
event: PostHogClient.EVENTS.UI.SETTINGS_OPENED,
properties: {
taskId,
},
})
}
/**
* Records when the task history view is opened
* @param taskId Optional task identifier if history was opened during a task
*/
public captureHistoryOpened(taskId?: string) {
this.capture({
event: PostHogClient.EVENTS.UI.HISTORY_OPENED,
properties: {
taskId,
},
})
}
/**
* Records when a task is removed from the task history
* @param taskId Unique identifier for the task being removed
*/
public captureTaskPopped(taskId: string) {
this.capture({
event: PostHogClient.EVENTS.UI.TASK_POPPED,
properties: {
taskId,
},
})
}
/**
* Records when a different model is selected for use
* @param model Name of the selected model
* @param provider Provider of the selected model
* @param taskId Optional task identifier if model was selected during a task
*/
public captureModelSelected(model: string, provider: string, taskId?: string) {
this.capture({
event: PostHogClient.EVENTS.UI.MODEL_SELECTED,
properties: {
model,
provider,
taskId,
},
})
}
/**
* Records when a historical task is loaded from storage
* @param taskId Unique identifier for the historical task
*/
public captureHistoricalTaskLoaded(taskId: string) {
this.capture({
event: PostHogClient.EVENTS.TASK.HISTORICAL_LOADED,
properties: {
taskId,
},
})
}
/**
* Records when the retry button is clicked for failed operations
* @param taskId Unique identifier for the task being retried
*/
public captureRetryClicked(taskId: string) {
this.capture({
event: PostHogClient.EVENTS.TASK.RETRY_CLICKED,
properties: {
taskId,
},
})
}
public isTelemetryEnabled(): boolean {
return this.telemetryEnabled
}
public async shutdown(): Promise<void> {
await this.client.shutdown()
}
}
export const telemetryService = PostHogClient.getInstance()
-2
View File
@@ -84,8 +84,6 @@ function separateFiles(allFiles: string[]): {
"java",
"php",
"swift",
// Kotlin
"kt",
].map((e) => `.${e}`)
const filesToParse = allFiles.filter((file) => extensions.includes(path.extname(file))).slice(0, 50) // 50 files max
const remainingFiles = allFiles.filter((file) => !filesToParse.includes(file))

Some files were not shown because too many files have changed in this diff Show More