mirror of
https://github.com/cline/cline.git
synced 2026-09-02 15:52:29 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 597457fdc6 |
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
Add Bedrock prompt caching support (optional).
|
||||
|
||||
This feature protected under checkbox because it is not yet rolled out to everyone, and if you will try to send cache headers, and its not enabled for you, you will get error.
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Added checkpoints warning when users start a multiroot task
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix Token Count when API incorrectly returns token count per chunk
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Checkpoints Telemetry
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Added markdown support to focus chain text, allowing the model to display more interesting focus chains
|
||||
@@ -8,18 +8,16 @@ Cline is a VSCode extension that provides AI assistance through a combination of
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph VSCodeExtensionHost[VSCode Extension Host]
|
||||
subgraph CoreExtension[Core Extension]
|
||||
subgraph VSCode Extension Host
|
||||
subgraph Core Extension
|
||||
ExtensionEntry[Extension Entry<br/>src/extension.ts]
|
||||
WebviewProvider[WebviewProvider<br/>src/core/webview/index.ts]
|
||||
Controller[Controller<br/>src/core/controller/index.ts]
|
||||
Task[Task<br/>src/core/task/index.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]
|
||||
McpHub[McpHub<br/>src/services/mcp/McpHub.ts]
|
||||
end
|
||||
|
||||
subgraph WebviewUI[Webview UI]
|
||||
subgraph Webview UI
|
||||
WebviewApp[React App<br/>webview-ui/src/App.tsx]
|
||||
ExtStateContext[ExtensionStateContext<br/>webview-ui/src/context/ExtensionStateContext.tsx]
|
||||
ReactComponents[React Components]
|
||||
@@ -29,101 +27,45 @@ graph TB
|
||||
TaskStorage[Task Storage<br/>Per-Task Files & History]
|
||||
CheckpointSystem[Git-based Checkpoints]
|
||||
end
|
||||
|
||||
subgraph apiProviders[API Providers]
|
||||
AnthropicAPI[Anthropic]
|
||||
OpenRouterAPI[OpenRouter]
|
||||
BedrockAPI[AWS Bedrock]
|
||||
OtherAPIs[Other Providers]
|
||||
end
|
||||
|
||||
subgraph MCPServers[MCP Servers]
|
||||
ExternalMcpServers[External MCP Servers]
|
||||
end
|
||||
end
|
||||
|
||||
%% Core Extension Data Flow
|
||||
ExtensionEntry --> WebviewProvider
|
||||
WebviewProvider --> Controller
|
||||
Controller --> Task
|
||||
Controller --> McpHub
|
||||
Task --> GlobalState
|
||||
Task --> SecretsStorage
|
||||
Task --> TaskStorage
|
||||
Task --> CheckpointSystem
|
||||
Task --> |API Requests| apiProviders
|
||||
McpHub --> |Connects to| ExternalMcpServers
|
||||
Task --> |Uses| McpHub
|
||||
ExtensionEntry --> ClineProvider
|
||||
ClineProvider --> ClineClass
|
||||
ClineClass --> GlobalState
|
||||
ClineClass --> SecretsStorage
|
||||
ClineClass --> TaskStorage
|
||||
ClineClass --> CheckpointSystem
|
||||
|
||||
%% Webview Data Flow
|
||||
WebviewApp --> ExtStateContext
|
||||
ExtStateContext --> ReactComponents
|
||||
|
||||
%% Bidirectional Communication
|
||||
WebviewProvider <-->|postMessage| ExtStateContext
|
||||
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 WebviewProvider fill:#bfb,stroke:#333,stroke-width:2px
|
||||
style McpHub fill:#bfb,stroke:#333,stroke-width:2px
|
||||
style apiProviders fill:#fdb,stroke:#333,stroke-width:2px
|
||||
style ClineProvider fill:#bfb,stroke:#333,stroke-width:2px
|
||||
```
|
||||
|
||||
## Definitions
|
||||
|
||||
- **Core Extension**: Anything inside the src folder, organized into modular components
|
||||
- **Core Extension State**: Managed by the Controller class in src/core/controller/index.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 components
|
||||
- **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 Architecture
|
||||
|
||||
The core extension follows a clear hierarchical structure:
|
||||
|
||||
1. **WebviewProvider** (src/core/webview/index.ts): Manages the webview lifecycle and communication
|
||||
2. **Controller** (src/core/controller/index.ts): Handles webview messages and task management
|
||||
3. **Task** (src/core/task/index.ts): Executes API requests and tool operations
|
||||
|
||||
This architecture provides clear separation of concerns:
|
||||
- WebviewProvider focuses on VSCode webview integration
|
||||
- Controller manages state and coordinates tasks
|
||||
- Task handles the execution of AI requests and tool operations
|
||||
|
||||
### WebviewProvider Implementation
|
||||
|
||||
The WebviewProvider class in `src/core/webview/index.ts` is responsible for:
|
||||
|
||||
- Managing multiple active instances through a static set (`activeInstances`)
|
||||
- Handling webview lifecycle events (creation, visibility changes, disposal)
|
||||
- Implementing HTML content generation with proper CSP headers
|
||||
- Supporting Hot Module Replacement (HMR) for development
|
||||
- Setting up message listeners between the webview and extension
|
||||
|
||||
The WebviewProvider maintains a reference to the Controller and delegates message handling to it. It also handles the creation of both sidebar and tab panel webviews, allowing Cline to be used in different contexts within VSCode.
|
||||
- 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 `Controller` class manages multiple types of persistent storage:
|
||||
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 `Controller` handles the distribution of state to both the core extension and webview components. It also coordinates state across multiple extension instances, ensuring consistency.
|
||||
|
||||
State synchronization between instances is handled through:
|
||||
- File-based storage for task history and conversation data
|
||||
- VSCode's global state API for settings and configuration
|
||||
- Secrets storage for sensitive information
|
||||
- Event listeners for file changes and configuration updates
|
||||
|
||||
The Controller implements methods for:
|
||||
- Saving and loading task state
|
||||
- Managing API configurations
|
||||
- Handling user authentication
|
||||
- Coordinating MCP server connections
|
||||
- Managing task history and checkpoints
|
||||
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
|
||||
|
||||
@@ -140,66 +82,16 @@ The `ExtensionStateContext` in `webview-ui/src/context/ExtensionStateContext.tsx
|
||||
|
||||
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`).
|
||||
|
||||
The ExtensionStateContext handles:
|
||||
- Real-time updates through message events
|
||||
- Partial message updates for streaming content
|
||||
- State modifications through setter methods
|
||||
- Type-safe access to state through a custom hook
|
||||
## Core Extension (Cline.ts)
|
||||
|
||||
## API Provider System
|
||||
|
||||
Cline supports multiple AI providers through a modular API provider system. Each provider is implemented as a separate module in the `src/api/providers/` directory and follows a common interface.
|
||||
|
||||
### API Provider Architecture
|
||||
|
||||
The API system consists of:
|
||||
|
||||
1. **API Handlers**: Provider-specific implementations in `src/api/providers/`
|
||||
2. **API Transformers**: Stream transformation utilities in `src/api/transform/`
|
||||
3. **API Configuration**: User settings for API keys and endpoints
|
||||
4. **API Factory**: Builder function to create the appropriate handler
|
||||
|
||||
Key providers include:
|
||||
- **Anthropic**: Direct integration with Claude models
|
||||
- **OpenRouter**: Meta-provider supporting multiple model providers
|
||||
- **AWS Bedrock**: Integration with Amazon's AI services
|
||||
- **Gemini**: Google's AI models
|
||||
- **Cerebras**: High-performance inference with Llama, Qwen, and DeepSeek models
|
||||
- **Ollama**: Local model hosting
|
||||
- **LM Studio**: Local model hosting
|
||||
- **VSCode LM**: VSCode's built-in language models
|
||||
|
||||
### API Configuration Management
|
||||
|
||||
API configurations are stored securely:
|
||||
- API keys are stored in VSCode's secrets storage
|
||||
- Model selections and non-sensitive settings are stored in global state
|
||||
- The Controller manages switching between providers and updating configurations
|
||||
|
||||
The system supports:
|
||||
- Secure storage of API keys
|
||||
- Model selection and configuration
|
||||
- Automatic retry and error handling
|
||||
- Token usage tracking and cost calculation
|
||||
- Context window management
|
||||
|
||||
### Plan/Act Mode API Configuration
|
||||
|
||||
Cline supports separate model configurations for Plan and Act modes:
|
||||
- Different models can be used for planning vs. execution
|
||||
- The system preserves model selections when switching modes
|
||||
- The Controller handles the transition between modes and updates the API configuration accordingly
|
||||
|
||||
## Task Execution System
|
||||
|
||||
The Task class is responsible for executing AI requests and tool operations. Each task runs in its own instance of the Task class, ensuring isolation and proper state management.
|
||||
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 Task {
|
||||
class Cline {
|
||||
async initiateTaskLoop(userContent: UserContent, isNewTask: boolean) {
|
||||
while (!this.abort) {
|
||||
// 1. Make API request and stream response
|
||||
@@ -210,7 +102,7 @@ class Task {
|
||||
switch (chunk.type) {
|
||||
case "text":
|
||||
// Parse into content blocks
|
||||
this.assistantMessageContent = parseAssistantMessageV2(chunk.text)
|
||||
this.assistantMessageContent = parseAssistantMessage(chunk.text)
|
||||
// Present blocks to user
|
||||
await this.presentAssistantMessage()
|
||||
break
|
||||
@@ -234,7 +126,7 @@ class Task {
|
||||
The streaming system handles real-time updates and partial content:
|
||||
|
||||
```typescript
|
||||
class Task {
|
||||
class Cline {
|
||||
async presentAssistantMessage() {
|
||||
// Handle streaming locks to prevent race conditions
|
||||
if (this.presentAssistantMessageLocked) {
|
||||
@@ -269,7 +161,7 @@ class Task {
|
||||
Tools follow a strict execution pattern:
|
||||
|
||||
```typescript
|
||||
class Task {
|
||||
class Cline {
|
||||
async executeToolWithApproval(block: ToolBlock) {
|
||||
// 1. Check auto-approval settings
|
||||
if (this.shouldAutoApproveTool(block.name)) {
|
||||
@@ -301,7 +193,7 @@ class Task {
|
||||
The system includes robust error handling:
|
||||
|
||||
```typescript
|
||||
class Task {
|
||||
class Cline {
|
||||
async handleError(action: string, error: Error) {
|
||||
// 1. Check if task was abandoned
|
||||
if (this.abandoned) return
|
||||
@@ -324,23 +216,23 @@ class Task {
|
||||
|
||||
### API Request & Token Management
|
||||
|
||||
The Task class handles API requests with built-in retry, streaming, and token management:
|
||||
The Cline class handles API requests with built-in retry, streaming, and token management:
|
||||
|
||||
```typescript
|
||||
class Task {
|
||||
class Cline {
|
||||
async *attemptApiRequest(previousApiReqIndex: number): ApiStream {
|
||||
// 1. Wait for MCP servers to connect
|
||||
await pWaitFor(() => this.controllerRef.deref()?.mcpHub?.isConnecting !== true)
|
||||
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 { tokensIn, tokensOut } = JSON.parse(previousRequest.text)
|
||||
const totalTokens = (tokensIn || 0) + (tokensOut || 0)
|
||||
|
||||
// Truncate conversation if approaching context limit
|
||||
if (totalTokens >= maxAllowedSize) {
|
||||
this.conversationHistoryDeletedRange = this.contextManager.getNextTruncationRange(
|
||||
this.conversationHistoryDeletedRange = getNextTruncationRange(
|
||||
this.apiConversationHistory,
|
||||
this.conversationHistoryDeletedRange,
|
||||
totalTokens / 2 > maxAllowedSize ? "quarter" : "half"
|
||||
@@ -360,7 +252,7 @@ class Task {
|
||||
} catch (error) {
|
||||
// 4. Error handling with retry
|
||||
if (isOpenRouter && !this.didAutomaticallyRetryFailedApiRequest) {
|
||||
await setTimeoutPromise(1000)
|
||||
await delay(1000)
|
||||
this.didAutomaticallyRetryFailedApiRequest = true
|
||||
yield* this.attemptApiRequest(previousApiReqIndex)
|
||||
return
|
||||
@@ -407,32 +299,16 @@ Key features:
|
||||
- Cost calculation
|
||||
- Cache hit monitoring
|
||||
|
||||
### Context Management System
|
||||
|
||||
The Context Management System handles conversation history truncation to prevent context window overflow errors. Implemented in the `ContextManager` class, it ensures long-running conversations remain within model context limits while preserving critical context.
|
||||
|
||||
Key features:
|
||||
|
||||
1. **Model-Aware Sizing**: Dynamically adjusts based on different model context windows (64K for DeepSeek, 128K for most models, 200K for Claude).
|
||||
|
||||
2. **Proactive Truncation**: Monitors token usage and preemptively truncates conversations when approaching limits, maintaining buffers of 27K-40K tokens depending on the model.
|
||||
|
||||
3. **Intelligent Preservation**: Always preserves the original task message and maintains the user-assistant conversation structure when truncating.
|
||||
|
||||
4. **Adaptive Strategies**: Uses different truncation strategies based on context pressure - removing half of the conversation for moderate pressure or three-quarters for severe pressure.
|
||||
|
||||
5. **Error Recovery**: Includes specialized detection for context window errors from different providers with automatic retry and more aggressive truncation when needed.
|
||||
|
||||
### Task State & Resumption
|
||||
|
||||
The Task class provides robust task state management and resumption capabilities:
|
||||
The Cline class provides robust task state management and resumption capabilities:
|
||||
|
||||
```typescript
|
||||
class Task {
|
||||
class Cline {
|
||||
async resumeTaskFromHistory() {
|
||||
// 1. Load saved state
|
||||
this.clineMessages = await getSavedClineMessages(this.getContext(), this.taskId)
|
||||
this.apiConversationHistory = await getSavedApiConversationHistory(this.getContext(), this.taskId)
|
||||
this.clineMessages = await this.getSavedClineMessages()
|
||||
this.apiConversationHistory = await this.getSavedApiConversationHistory()
|
||||
|
||||
// 2. Handle interrupted tool executions
|
||||
const lastMessage = this.apiConversationHistory[this.apiConversationHistory.length - 1]
|
||||
@@ -462,14 +338,14 @@ class Task {
|
||||
|
||||
private async saveTaskState() {
|
||||
// Save conversation history
|
||||
await saveApiConversationHistory(this.getContext(), this.taskId, this.apiConversationHistory)
|
||||
await saveClineMessages(this.getContext(), this.taskId, this.clineMessages)
|
||||
await this.saveApiConversationHistory()
|
||||
await this.saveClineMessages()
|
||||
|
||||
// Create checkpoint
|
||||
const commitHash = await this.checkpointTracker?.commit()
|
||||
|
||||
// Update task history
|
||||
await this.controllerRef.deref()?.updateTaskHistory({
|
||||
await this.providerRef.deref()?.updateTaskHistory({
|
||||
id: this.taskId,
|
||||
ts: lastMessage.ts,
|
||||
task: taskMessage.text,
|
||||
@@ -505,54 +381,11 @@ Key aspects of task state management:
|
||||
- Resources are cleaned up properly
|
||||
- User is notified of state changes
|
||||
|
||||
## Plan/Act Mode System
|
||||
|
||||
Cline implements a dual-mode system that separates planning from execution:
|
||||
|
||||
### Mode Architecture
|
||||
|
||||
The Plan/Act mode system consists of:
|
||||
|
||||
1. **Mode State**: Stored in `chatSettings.mode` in the Controller's state
|
||||
2. **Mode Switching**: Handled by `togglePlanActModeWithChatSettings` in the Controller
|
||||
3. **Mode-specific Models**: Optional configuration to use different models for each mode
|
||||
4. **Mode-specific Prompting**: Different system prompts for planning vs. execution
|
||||
|
||||
### Mode Switching Process
|
||||
|
||||
When switching between modes:
|
||||
|
||||
1. The current model configuration is saved to mode-specific state
|
||||
2. The previous mode's model configuration is restored
|
||||
3. The Task instance is updated with the new mode
|
||||
4. The webview is notified of the mode change
|
||||
5. Telemetry events are captured for analytics
|
||||
|
||||
### Plan Mode
|
||||
|
||||
Plan mode is designed for:
|
||||
- Information gathering and context building
|
||||
- Asking clarifying questions
|
||||
- Creating detailed execution plans
|
||||
- Discussing approaches with the user
|
||||
|
||||
In Plan mode, the AI uses the `plan_mode_respond` tool to engage in conversational planning without executing actions.
|
||||
|
||||
### Act Mode
|
||||
|
||||
Act mode is designed for:
|
||||
- Executing the planned actions
|
||||
- Using tools to modify files, run commands, etc.
|
||||
- Implementing the solution
|
||||
- Providing results and completion feedback
|
||||
|
||||
In Act mode, the AI has access to all tools except `plan_mode_respond` and focuses on implementation rather than discussion.
|
||||
|
||||
## Data Flow & State Management
|
||||
|
||||
### Core Extension Role
|
||||
|
||||
The Controller acts as the single source of truth for all persistent state. It:
|
||||
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
|
||||
@@ -561,10 +394,10 @@ The Controller acts as the single source of truth for all persistent state. It:
|
||||
|
||||
### Terminal Management
|
||||
|
||||
The Task class manages terminal instances and command execution:
|
||||
The Cline class manages terminal instances and command execution:
|
||||
|
||||
```typescript
|
||||
class Task {
|
||||
class Cline {
|
||||
async executeCommandTool(command: string): Promise<[boolean, ToolResponse]> {
|
||||
// 1. Get or create terminal
|
||||
const terminalInfo = await this.terminalManager.getOrCreateTerminal(cwd)
|
||||
@@ -620,10 +453,10 @@ Key features:
|
||||
|
||||
### Browser Session Management
|
||||
|
||||
The Task class handles browser automation through Puppeteer:
|
||||
The Cline class handles browser automation through Puppeteer:
|
||||
|
||||
```typescript
|
||||
class Task {
|
||||
class Cline {
|
||||
async executeBrowserAction(action: BrowserAction): Promise<BrowserActionResult> {
|
||||
switch (action) {
|
||||
case "launch":
|
||||
@@ -660,93 +493,13 @@ Key aspects:
|
||||
- Screenshot capture
|
||||
- Error recovery
|
||||
|
||||
## MCP (Model Context Protocol) Integration
|
||||
|
||||
### MCP Architecture
|
||||
|
||||
The MCP system consists of:
|
||||
|
||||
1. **McpHub Class**: Central manager in `src/services/mcp/McpHub.ts`
|
||||
2. **MCP Connections**: Manages connections to external MCP servers
|
||||
3. **MCP Settings**: Configuration stored in a JSON file
|
||||
4. **MCP Marketplace**: Online catalog of available MCP servers
|
||||
5. **MCP Tools & Resources**: Capabilities exposed by connected servers
|
||||
|
||||
The McpHub class:
|
||||
- Manages the lifecycle of MCP server connections
|
||||
- Handles server configuration through a settings file
|
||||
- Provides methods for calling tools and accessing resources
|
||||
- Implements auto-approval settings for MCP tools
|
||||
- Monitors server health and handles reconnection
|
||||
|
||||
### MCP Server Types
|
||||
|
||||
Cline supports two types of MCP server connections:
|
||||
- **Stdio**: Command-line based servers that communicate via standard I/O
|
||||
- **SSE**: HTTP-based servers that communicate via Server-Sent Events
|
||||
|
||||
### MCP Server Management
|
||||
|
||||
The McpHub class provides methods for:
|
||||
- Discovering and connecting to MCP servers
|
||||
- Monitoring server health and status
|
||||
- Restarting servers when needed
|
||||
- Managing server configurations
|
||||
- Setting timeouts and auto-approval rules
|
||||
|
||||
### MCP Tool Integration
|
||||
|
||||
MCP tools are integrated into the Task execution system:
|
||||
- Tools are discovered and registered at connection time
|
||||
- The Task class can call MCP tools through the McpHub
|
||||
- Tool results are streamed back to the AI
|
||||
- Auto-approval settings can be configured per tool
|
||||
|
||||
### MCP Marketplace
|
||||
|
||||
The MCP Marketplace provides:
|
||||
- A catalog of available MCP servers
|
||||
- One-click installation
|
||||
- README previews
|
||||
- Server status monitoring
|
||||
|
||||
The Controller class manages MCP servers through the McpHub service:
|
||||
|
||||
```typescript
|
||||
class Controller {
|
||||
mcpHub?: McpHub
|
||||
|
||||
constructor(context: vscode.ExtensionContext, webviewProvider: WebviewProvider) {
|
||||
this.mcpHub = new McpHub(this)
|
||||
}
|
||||
|
||||
async downloadMcp(mcpId: string) {
|
||||
// Fetch server details from marketplace
|
||||
const response = await axios.post<McpDownloadResponse>(
|
||||
"https://api.cline.bot/v1/mcp/download",
|
||||
{ mcpId },
|
||||
{
|
||||
headers: { "Content-Type": "application/json" },
|
||||
timeout: 10000,
|
||||
}
|
||||
)
|
||||
|
||||
// Create task with context from README
|
||||
const task = `Set up the MCP server from ${mcpDetails.githubUrl}...`
|
||||
|
||||
// Initialize task and show chat view
|
||||
await this.initClineWithTask(task)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 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 follows a WebviewProvider -> Controller -> Task flow
|
||||
- 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
|
||||
@@ -1,89 +0,0 @@
|
||||
# Cline Protobuf Development Guide
|
||||
|
||||
This guide outlines how to add new gRPC endpoints for communication between the webview (frontend) and the extension host (backend).
|
||||
|
||||
## Overview
|
||||
|
||||
Cline uses [Protobuf](https://protobuf.dev/) to define a strongly-typed API, ensuring efficient and type-safe communication. All definitions are in the `/proto` directory. The compiler and plugins are included as project dependencies, so no manual installation is needed.
|
||||
|
||||
## Key Concepts & Best Practices
|
||||
|
||||
- **File Structure**: Each feature domain should have its own `.proto` file (e.g., `account.proto`, `task.proto`).
|
||||
- **Message Design**:
|
||||
- For simple, single-value data, use the shared types in `proto/common.proto` (e.g., `StringRequest`, `Empty`, `Int64Request`). This promotes consistency.
|
||||
- For complex data structures, define custom messages within the feature's `.proto` file (see `task.proto` for examples like `NewTaskRequest`).
|
||||
- **Naming Conventions**:
|
||||
- Services: `PascalCaseService` (e.g., `AccountService`).
|
||||
- RPCs: `camelCase` (e.g., `accountEmailIdentified`).
|
||||
- Messages: `PascalCase` (e.g., `StringRequest`).
|
||||
- **Streaming**: For server-to-client streaming, use the `stream` keyword on the response type. See `subscribeToAuthCallback` in `account.proto` for an example.
|
||||
|
||||
---
|
||||
|
||||
## 4-Step Development Workflow
|
||||
|
||||
Here’s how to add a new RPC, using `scrollToSettings` as an example.
|
||||
|
||||
### 1. Define the RPC in a `.proto` File
|
||||
|
||||
Add your service method to the appropriate file in the `proto/` directory.
|
||||
|
||||
**File: `proto/ui.proto`**
|
||||
```proto
|
||||
service UiService {
|
||||
// ... other RPCs
|
||||
// Scrolls to a specific settings section in the settings view
|
||||
rpc scrollToSettings(StringRequest) returns (KeyValuePair);
|
||||
}
|
||||
```
|
||||
Here, we use the common `StringRequest` and `KeyValuePair` types.
|
||||
|
||||
### 2. Compile Definitions
|
||||
|
||||
After editing a `.proto` file, regenerate the TypeScript code. From the project root, run:
|
||||
```bash
|
||||
npm run protos
|
||||
```
|
||||
This command compiles all `.proto` files and outputs the generated code to `src/generated/` and `src/shared/`. Do not edit these generated files manually.
|
||||
|
||||
### 3. Implement the Backend Handler
|
||||
|
||||
Create the RPC implementation in the backend. Handlers are located in `src/core/controller/[service-name]/`.
|
||||
|
||||
**File: `src/core/controller/ui/scrollToSettings.ts`**
|
||||
```typescript
|
||||
import { Controller } from ".."
|
||||
import { StringRequest, KeyValuePair } from "../../../shared/proto/common"
|
||||
|
||||
/**
|
||||
* Executes a scroll to settings action
|
||||
* @param controller The controller instance
|
||||
* @param request The request containing the ID of the settings section to scroll to
|
||||
* @returns KeyValuePair with action and value fields for the UI to process
|
||||
*/
|
||||
export async function scrollToSettings(controller: Controller, request: StringRequest): Promise<KeyValuePair> {
|
||||
return KeyValuePair.create({
|
||||
key: "scrollToSettings",
|
||||
value: request.value || "",
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Call the RPC from the Webview
|
||||
|
||||
Call the new RPC from a React component in `webview-ui/`. The generated client makes this simple.
|
||||
|
||||
**File: `webview-ui/src/components/browser/BrowserSettingsMenu.tsx`** (Example)
|
||||
```tsx
|
||||
import { UiServiceClient } from "../../../services/grpc"
|
||||
import { StringRequest } from "../../../../shared/proto/common"
|
||||
|
||||
// ... inside a React component
|
||||
const handleMenuClick = async () => {
|
||||
try {
|
||||
await UiServiceClient.scrollToSettings(StringRequest.create({ value: "browser" }))
|
||||
} catch (error) {
|
||||
console.error("Error scrolling to browser settings:", error)
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1,549 +0,0 @@
|
||||
The goal of this workflow is to take a changeset for a release of Cline, an autonomous coding agent extension that plugs right into your IDE, and write the updated announcement component, and the updated changelog.
|
||||
|
||||
|
||||
For reference, here are some examples of how we converted previous changesets to announcement components / changelogs.
|
||||
|
||||
|
||||
- 3.14
|
||||
<changeset>
|
||||
This PR was opened by the Changesets release GitHub action. When you're ready to do a release, you can merge this and publish to npm yourself or setup this action to publish automatically. If you're not ready to do a release yet, that's fine, whenever you add more changesets to main, this PR will be updated.
|
||||
|
||||
Releases
|
||||
claude-dev@3.14.0
|
||||
Minor Changes
|
||||
77c9863: create clinerules folder if its currently a file and creating new rule
|
||||
0ffb7dd: disabling shift hint for now & improving tooltip behavior
|
||||
79b76fd: Add support for custom model ID in AWS Bedrock provider, enabling use of Application Inference Profile.
|
||||
eb6e481: Full support for LaTeX rendering
|
||||
df37f29: Add support for custom API request timeout. Previously, timeouts were hardcoded to 30 seconds for providers like Ollama or 15 seconds for OpenRouter and Cline. Now users can set a custom timeout value in milliseconds through the settings interface.
|
||||
e4d26be: allow cursorrules and windsurfrules
|
||||
c5de50f: Fix Handle @withRetry() SyntaxError when running extension locally issue
|
||||
61d2f42: enabled pricing calculation for gemini and vertex + more robust caching & cache tracking for gemini & vertex
|
||||
aed152b: add truncation notice when truncating manually
|
||||
2fe2405: Migrate Cline Tools Section to new docs
|
||||
19cc8bc: Add a timeout setting for the terminal connection, allowing users to adjust this if they are having timeout issues
|
||||
03d4410: Added copy button to code blocks.
|
||||
c78fe23: addressed race condition in terminal command usage
|
||||
91e222f: add checkpoints after more messages
|
||||
14230e7: add newrule slash command
|
||||
1c7d33a: Add remote config with posthog allowing for disabling new features until they're reading, making for a better developer experience.
|
||||
4196c14: add cache ui for open router and cline provider
|
||||
d97424f: showing expanded task by default
|
||||
5294e78: Refactor to not pass a message for showing the MCP View from the servers modal
|
||||
70cc437: Fix Windows path issue: Correct handling of import.meta.url to avoid leading slash in pathname
|
||||
4b697d8: Migrate the addRemoteServer to protobus
|
||||
Patch Changes
|
||||
c63d9a1: updated drag and drop text to say "drop" instead of "drag"
|
||||
459adf0: Add markdown copy to chat
|
||||
74ec823: Minor UX improvement to drag and drop ux
|
||||
b0961f4: Remove linear pull request action
|
||||
e9ce384: searchCommits protobus migration
|
||||
5802b68: createRuleFile protobus migration
|
||||
df7f9fc: Add dependsOn to more blocks in the tasks.json
|
||||
41ae732: Fix for git commit mentions in repos with no git commits
|
||||
7e78445: Adding args to allow Cursor to open workspaces (for checkpoint testing/development)
|
||||
bdfda6f: feat(bedrock): Introduce Amazon Nova Premier
|
||||
65243ad: Introduce UI library for future UI development
|
||||
4565e06: checkIsImageURL migrated to protobus
|
||||
5a8e9d8: protobus migration for openImage
|
||||
deeda6e: Lowering Gemini cache TTL time
|
||||
db0b022: Adding UI to show openrouter balance next to provider
|
||||
4650ffa: deleteRuleFile protobus migration
|
||||
d4bd755: fix cost calculation
|
||||
</changeset>
|
||||
|
||||
<changelog>
|
||||
## [3.14.0]
|
||||
|
||||
- Add UI to show openrouter balance next to provider
|
||||
- Add support for custom model ID in AWS Bedrock provider, enabling use of Application Inference Profile (Thanks @clicube!)
|
||||
- Add more robust caching & cache tracking for gemini & vertex providers
|
||||
- Add support for LaTeX rendering
|
||||
- Add support for custom API request timeout. Timeouts were 15-30s, but can now be configured via settings for OpenRouter/Cline & Ollama (Thanks @WingsDrafterwork!)
|
||||
- Add truncation notice when truncating manually
|
||||
- Add a timeout setting for the terminal connection, allowing users to set a time to wait for terminal startup
|
||||
- Add copy button to code blocks
|
||||
- Add copy button to markdown blocks (Thanks @weshoke!)
|
||||
- Add checkpoints to more messages
|
||||
- Add slash command to create a new rules file (/newrule)
|
||||
- Add cache ui for open router and cline provider
|
||||
- Add Amazon Nova Premier model to Bedrock (Thanks @watany!)
|
||||
- Add support for cursorrules and windsurfrules
|
||||
- Add support for batch history deletion (Thanks @danix800!)
|
||||
- Improve Drag & Drop experience
|
||||
- Create clinerules folder creating new rule if it's needed
|
||||
- Enable pricing calculation for gemini and vertex providers
|
||||
- Refactor message handling to not show the MCP View of the server modal
|
||||
- Migrate the addRemoteServer to protobus (Thanks @DaveFres!)
|
||||
- Update task header to be expanded by default
|
||||
- Update Gemini cache TTL time to 15 minutes
|
||||
- Fix race condition in terminal command usage
|
||||
- Fix to correctly handle `import.meta.url`, avoiding leading slash in pathname for Windows (Thanks @DaveFres!)
|
||||
- Fix @withRetry() decoration syntax error when running extension locally (Thanks @DaveFres!)
|
||||
- Fix for git commit mentions in repos with no git commits
|
||||
- Fix cost calculation (Thanks @BarreiroT!)
|
||||
</changelog>
|
||||
|
||||
|
||||
<announcement-component>
|
||||
const Announcement = ({ version, hideAnnouncement }: AnnouncementProps) => {
|
||||
const minorVersion = version.split(".").slice(0, 2).join(".") // 2.0.0 -> 2.0
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<VSCodeButton appearance="icon" onClick={hideAnnouncement} style={closeIconStyle}>
|
||||
<span className="codicon codicon-close"></span>
|
||||
</VSCodeButton>
|
||||
<h3 style={h3TitleStyle}>
|
||||
🎉{" "}New in v{minorVersion}
|
||||
</h3>
|
||||
<ul style={ulStyle}>
|
||||
<li>
|
||||
<b>Gemini prompt caching:</b> Gemini and Vertex providers now support prompt caching and price tracking for
|
||||
Gemini models.
|
||||
</li>
|
||||
<li>
|
||||
<b>Copy Buttons:</b> Buttons were added to Markdown and Code blocks that allow you to copy their contents
|
||||
easily.
|
||||
</li>
|
||||
<li>
|
||||
<b>/newrule command:</b> New slash command to have cline write your .clinerules for you based on your
|
||||
workflow.
|
||||
</li>
|
||||
<li>
|
||||
<b>Drag and drop improvements:</b> Don't forget to hold shift while dragging files!
|
||||
</li>
|
||||
<li>Added more checkpoints across the task, allowing you to restore from more than just file changes.</li>
|
||||
<li>Added support for rendering LaTeX in message responses. (Try asking Cline to show the quadratic formula)</li>
|
||||
</ul>
|
||||
<Accordion isCompact className="pl-0">
|
||||
<AccordionItem
|
||||
key="1"
|
||||
aria-label="Previous Updates"
|
||||
title="Previous Updates:"
|
||||
classNames={{
|
||||
trigger: "bg-transparent border-0 pl-0 pb-0 w-fit",
|
||||
title: "font-bold text-[var(--vscode-foreground)]",
|
||||
indicator:
|
||||
"text-[var(--vscode-foreground)] mb-0.5 -rotate-180 data-[open=true]:-rotate-90 rtl:rotate-0 rtl:data-[open=true]:-rotate-90",
|
||||
}}>
|
||||
<ul style={ulStyle}>
|
||||
<li>
|
||||
<b>Global Cline Rules:</b> store multiple rules files in Documents/Cline/Rules to share between
|
||||
projects.
|
||||
</li>
|
||||
<li>
|
||||
<b>Cline Rules Popup:</b> New button in the chat area to view workspace and global cline rules files
|
||||
to plug and play specific rules for the task
|
||||
</li>
|
||||
<li>
|
||||
<b>Slash Commands:</b> Type <code>/</code> in chat to see the list of quick actions, like starting a
|
||||
new task (more coming soon!)
|
||||
</li>
|
||||
<li>
|
||||
<b>Edit Messages:</b> You can now edit a message you sent previously by clicking on it. Optionally
|
||||
restore your project when the message was sent!
|
||||
</li>
|
||||
</ul>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
{/*
|
||||
// Leave this here for an example of how to structure the announcement
|
||||
<ul style={{ margin: "0 0 8px", paddingLeft: "12px" }}>
|
||||
<li>
|
||||
OpenRouter now supports prompt caching! They also have much higher rate limits than other providers,
|
||||
so I recommend trying them out.
|
||||
<br />
|
||||
{!apiConfiguration?.openRouterApiKey && (
|
||||
<VSCodeButtonLink
|
||||
href={getOpenRouterAuthUrl(vscodeUriScheme)}
|
||||
style={{
|
||||
transform: "scale(0.85)",
|
||||
transformOrigin: "left center",
|
||||
margin: "4px -30px 2px 0",
|
||||
}}>
|
||||
Get OpenRouter API Key
|
||||
</VSCodeButtonLink>
|
||||
)}
|
||||
{apiConfiguration?.openRouterApiKey && apiConfiguration?.apiProvider !== "openrouter" && (
|
||||
<VSCodeButton
|
||||
onClick={() => {
|
||||
vscode.postMessage({
|
||||
type: "apiConfiguration",
|
||||
apiConfiguration: { ...apiConfiguration, apiProvider: "openrouter" },
|
||||
})
|
||||
}}
|
||||
style={{
|
||||
transform: "scale(0.85)",
|
||||
transformOrigin: "left center",
|
||||
margin: "4px -30px 2px 0",
|
||||
}}>
|
||||
Switch to OpenRouter
|
||||
</VSCodeButton>
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<b>Edit Cline's changes before accepting!</b> When he creates or edits a file, you can modify his
|
||||
changes directly in the right side of the diff view (+ hover over the 'Revert Block' arrow button in
|
||||
the center to undo "<code>{"// rest of code here"}</code>" shenanigans)
|
||||
</li>
|
||||
<li>
|
||||
New <code>search_files</code> tool that lets Cline perform regex searches in your project, letting
|
||||
him refactor code, address TODOs and FIXMEs, remove dead code, and more!
|
||||
</li>
|
||||
<li>
|
||||
When Cline runs commands, you can now type directly in the terminal (+ support for Python
|
||||
environments)
|
||||
</li>
|
||||
</ul>*/}
|
||||
<div style={hrStyle} />
|
||||
<p style={linkContainerStyle}>
|
||||
Join us on{" "}
|
||||
<VSCodeLink style={linkStyle} href="https://x.com/cline">
|
||||
X,
|
||||
</VSCodeLink>{" "}
|
||||
<VSCodeLink style={linkStyle} href="https://discord.gg/cline">
|
||||
discord,
|
||||
</VSCodeLink>{" "}
|
||||
or{" "}
|
||||
<VSCodeLink style={linkStyle} href="https://www.reddit.com/r/cline/">
|
||||
r/cline
|
||||
</VSCodeLink>
|
||||
for more updates!
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</announcement-component>
|
||||
|
||||
- 3.13
|
||||
|
||||
<changeset>
|
||||
Minor Changes
|
||||
2964388: Added copy button to MermaidBlock component
|
||||
75143a7: Add the ability to fetch from global cline rules files
|
||||
Patch Changes
|
||||
a0252e7: convert inline style to tailwind css of file SettingsView.tsx
|
||||
ab59bd9: Add stream options back to xai provider
|
||||
7276f50: Icons to indicate an action is occuring outside of the users workspace
|
||||
0b19ba6: update to NEW model
|
||||
</changeset>
|
||||
|
||||
<changelog>
|
||||
## [3.13.0]
|
||||
|
||||
- Add Cline rules popover under the chat field, allowing you to easily add, enable & disable workspace level or global rule files
|
||||
- Add new slash command menu letting you type “/“ to do quick actions like creating new tasks
|
||||
- Add ability to edit past messages, with options to restore your workspace back to that point
|
||||
- Allow sending a message when selecting an option provided by the question or plan tool
|
||||
- Add command to jump to Cline's chat input
|
||||
- Add support for OpenAI o3 & 4o-mini (Thanks @PeterDaveHello and @arafatkatze!)
|
||||
- Add baseURL option for Google Gemini provider (Thanks @owengo and @olivierhub!)
|
||||
- Add support for Azure's DeepSeek model. (Thanks @yt3trees!)
|
||||
- Add ability for models that support it to receive image responses from MCP servers (Thanks @rikaaa0928!)
|
||||
- Improve search and replace diff editing by making it more flexible with models that fail to follow structured output instructions. (Thanks @chi-cat!)
|
||||
- Add detection of Ctrl+C termination in terminal, improving output reading issues
|
||||
- Fix issue where some commands with large output would cause UI to freeze
|
||||
- Fix token usage tracking issues with vertex provider (Thanks @mzsima!)
|
||||
- Fix issue with xAI reasoning content not being parsed (Thanks @mrubens!)
|
||||
</changelog>
|
||||
|
||||
<announcement-component>
|
||||
const Announcement = ({ version, hideAnnouncement }: AnnouncementProps) => {
|
||||
const minorVersion = version.split(".").slice(0, 2).join(".") // 2.0.0 -> 2.0
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<VSCodeButton appearance="icon" onClick={hideAnnouncement} style={closeIconStyle}>
|
||||
<span className="codicon codicon-close"></span>
|
||||
</VSCodeButton>
|
||||
<h3 style={h3TitleStyle}>
|
||||
🎉{" "}New in v{minorVersion}
|
||||
</h3>
|
||||
<ul style={ulStyle}>
|
||||
<li>
|
||||
<b>Global Cline Rules:</b> store multiple rules files in Documents/Cline/Rules to share between projects.
|
||||
</li>
|
||||
<li>
|
||||
<b>Cline Rules Popup:</b> New button in the chat area to view workspace and global cline rules files to plug
|
||||
and play specific rules for the task
|
||||
</li>
|
||||
<li>
|
||||
<b>Slash Commands:</b> Type <code>/</code> in chat to see the list of quick actions, like starting a new task
|
||||
(more coming soon!)
|
||||
</li>
|
||||
<li>
|
||||
<b>Edit Messages:</b> You can now edit a message you sent previously by clicking on it. Optionally restore
|
||||
your project when the message was sent!
|
||||
</li>
|
||||
</ul>
|
||||
<h4 style={{ margin: "5px 0 5px" }}>Previous Updates:</h4>
|
||||
<ul style={ulStyle}>
|
||||
<li>
|
||||
<b>Model Favorites:</b> You can now mark your favorite models when using Cline & OpenRouter providers for
|
||||
quick access!
|
||||
</li>
|
||||
<li>
|
||||
<b>Faster Diff Editing:</b> Improved animation performance for large files, plus a new indicator in chat
|
||||
showing the number of edits Cline makes.
|
||||
</li>
|
||||
<li>
|
||||
<b>New Auto-Approve Options:</b> Turn off Cline's ability to read and edit files outside your workspace.
|
||||
</li>
|
||||
</ul>
|
||||
{/*
|
||||
// Leave this here for an example of how to structure the announcement
|
||||
<ul style={{ margin: "0 0 8px", paddingLeft: "12px" }}>
|
||||
<li>
|
||||
OpenRouter now supports prompt caching! They also have much higher rate limits than other providers,
|
||||
so I recommend trying them out.
|
||||
<br />
|
||||
{!apiConfiguration?.openRouterApiKey && (
|
||||
<VSCodeButtonLink
|
||||
href={getOpenRouterAuthUrl(vscodeUriScheme)}
|
||||
style={{
|
||||
transform: "scale(0.85)",
|
||||
transformOrigin: "left center",
|
||||
margin: "4px -30px 2px 0",
|
||||
}}>
|
||||
Get OpenRouter API Key
|
||||
</VSCodeButtonLink>
|
||||
)}
|
||||
{apiConfiguration?.openRouterApiKey && apiConfiguration?.apiProvider !== "openrouter" && (
|
||||
<VSCodeButton
|
||||
onClick={() => {
|
||||
vscode.postMessage({
|
||||
type: "apiConfiguration",
|
||||
apiConfiguration: { ...apiConfiguration, apiProvider: "openrouter" },
|
||||
})
|
||||
}}
|
||||
style={{
|
||||
transform: "scale(0.85)",
|
||||
transformOrigin: "left center",
|
||||
margin: "4px -30px 2px 0",
|
||||
}}>
|
||||
Switch to OpenRouter
|
||||
</VSCodeButton>
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<b>Edit Cline's changes before accepting!</b> When he creates or edits a file, you can modify his
|
||||
changes directly in the right side of the diff view (+ hover over the 'Revert Block' arrow button in
|
||||
the center to undo "<code>{"// rest of code here"}</code>" shenanigans)
|
||||
</li>
|
||||
<li>
|
||||
New <code>search_files</code> tool that lets Cline perform regex searches in your project, letting
|
||||
him refactor code, address TODOs and FIXMEs, remove dead code, and more!
|
||||
</li>
|
||||
<li>
|
||||
When Cline runs commands, you can now type directly in the terminal (+ support for Python
|
||||
environments)
|
||||
</li>
|
||||
</ul>*/}
|
||||
<div style={hrStyle} />
|
||||
<p style={linkContainerStyle}>
|
||||
Join us on{" "}
|
||||
<VSCodeLink style={linkStyle} href="https://x.com/cline">
|
||||
X,
|
||||
</VSCodeLink>{" "}
|
||||
<VSCodeLink style={linkStyle} href="https://discord.gg/cline">
|
||||
discord,
|
||||
</VSCodeLink>{" "}
|
||||
or{" "}
|
||||
<VSCodeLink style={linkStyle} href="https://www.reddit.com/r/cline/">
|
||||
r/cline
|
||||
</VSCodeLink>
|
||||
for more updates!
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</announcement-component>
|
||||
|
||||
|
||||
We have a changeset PR that automatically generated as new unreleased PRs are merged into main, the PR is always called "Changeset version bump" and the author is github-actions.
|
||||
|
||||
The Changeset PR description looks something like this:
|
||||
|
||||
<changeset-pr-description>
|
||||
This PR was opened by the [Changesets release](https://github.com/changesets/action) GitHub action. When you're ready to do a release, you can merge this and publish to npm yourself or [setup this action to publish automatically](https://github.com/changesets/action#with-publishing). If you're not ready to do a release yet, that's fine, whenever you add more changesets to main, this PR will be updated.
|
||||
|
||||
|
||||
# Releases
|
||||
## claude-dev@3.16.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- c6e8b04: Recent task list is now collapsible, allowing users to hide their recent tasks (e.g. when sharing their screen).
|
||||
- aabe4ae: Add detection for new users to display special components
|
||||
- 6c18d51: adds global endpoint for vertex ai users
|
||||
- 080ed7c: Add Tailwind CSS IntelliSense to the the recommended extensions list
|
||||
- 5147e28: new workflow feature
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- c0b3c69: fix eternal loading states when the last message is a checkpoint
|
||||
- 570ece3: selectImages protos migration
|
||||
- 8d8452e: askResponse protobus migration
|
||||
- cd1ff2a: Finishing the migration of Vscode Advanced settings to Settings Webview
|
||||
</changeset-pr-description>
|
||||
|
||||
The changeset pr is ALWAYS on the following branch: `changeset-release/main`.
|
||||
|
||||
I have the `gh` command line tool set up and authenticated, so you have everything you need.
|
||||
|
||||
The first step is to get the full diff from the changeset PR to look at the changes that were automatically made to the `CHANGELOG.md` file. By default it will automatically add a new section to the changelog.md file with the new version. The problem with the automatically generated section is that it just takes the text that the developers threw into their changeset files for each corresponding PR, and they can be pretty vague and bad. Additionally there's some stuff that is totally irrelevant for the end user, like minor refactoring changes. So I manually typically go in and update this section to be a proper changelog that will show up in our patchnotes. You can look at how the rest of the file is done because those are all good examples of us updating this to use good language for the end user. We usually put new features up top (and the most exciting flagship features at the very top), and then bug fixes/improvements at the bottom. Having some basic organization to the ordering of the bullet points by content is nice. But use common sense.
|
||||
|
||||
To handle this process effectively, do the following:
|
||||
|
||||
For each of the automatically generated bullet points in the Changelog.md, you should
|
||||
1. Take the commit hash at the start of the bullet point, and use the `gh` command line tool find the PR that it was associated with.
|
||||
2. Use the `gh` command to get the PR title/description/discussion to understand the context surrounding the PR.
|
||||
3. Use the `gh` command line tool to get the full PR diff to fully understand the changes made in the code.
|
||||
4. Synthesize that knowledge to determine (a) whether or not this change is relevant to end users and (b) what the text & ordering of the line should be.
|
||||
5. Update the `CHANGELOG.md` accordingly
|
||||
|
||||
Do this for every single item in the list from the autogenerated bullet points. We want to be diligent and have a full understanding of every feature so we can make the best changelog ever!
|
||||
|
||||
Here are some principles for good changelogs from keepchangelog.com, a handy guide:
|
||||
|
||||
<keepachangelog-pinciples-for-good-changelogs>
|
||||
### Guiding Principles
|
||||
- Changelogs are for humans, not machines.
|
||||
- There should be an entry for every single version.
|
||||
- The same types of changes should be grouped.
|
||||
- The latest version comes first.
|
||||
|
||||
### Bullet points in the changelog should follow these principles:
|
||||
- Types of changes
|
||||
- Added for new features.
|
||||
- Changed for changes in existing functionality.
|
||||
- Deprecated for soon-to-be removed features.
|
||||
- Removed for now removed features.
|
||||
- Fixed for any bug fixes.
|
||||
- Security in case of vulnerabilities.
|
||||
</keepachangelog-pinciples-for-good-changelogs>
|
||||
|
||||
Lastly, when developers make a PR, they typically make a changeset. And they have 3 options when making the changeset:
|
||||
|
||||
1. Patch
|
||||
2. Minor
|
||||
3. Major
|
||||
|
||||
Sometimes they label something as minor when really it should just be a patch. Or vice versa. Because of this, the automatic version bump may be incorrect. So when starting out this workflow, you should use the <ask_followup_question> tool to confirm with me whether or not this should be a patch bump (show the old version number and what the proposed new version number would be) or a minor bump. Part of the release process is making sure the version in package.json that is automatically changed actually corresponds with what we decided the bump should actually be based on the features. ALL these modifications happen in the `changeset-release/main` branch btw.
|
||||
|
||||
<important_note>
|
||||
Before doing any of this, make sure you check out the `changeset-release/main` and pull the most recent up to date changes. Then perform all this work in that branch.
|
||||
|
||||
New announcement banners should ONLY be made for minor version bumps or higher. That's another reason why double checking if the changelog warrants the bump is important.
|
||||
|
||||
Also, SUPER important: For any external contributors that aren't part of the cline github organization, we always want to add a (Thanks @username!) at the end of the changelog to attribute them properly. We're an open source project and it's ethical to do this.
|
||||
</important_note>
|
||||
|
||||
Once the changelog looks good, and the version number looks good, we gotta double check that the version number in the changelog has the brackets around it. And as a final step, double check the package.json version number matches the latest number in the changelog. And as the ultimate final step we run `npm run install:all` to make sure the package version number permiates through the lock file.
|
||||
|
||||
|
||||
<detailed_sequence_of_steps>
|
||||
# Cline Release Process - Detailed Sequence of Steps
|
||||
|
||||
## Before Starting
|
||||
1. First, examine the changeset PR without checking it out:
|
||||
```bash
|
||||
gh pr view changeset-release/main
|
||||
```
|
||||
|
||||
2. View the PR diff to see the auto-generated CHANGELOG.md changes:
|
||||
```bash
|
||||
gh pr diff changeset-release/main > changeset-diff.txt
|
||||
cat changeset-diff.txt | grep -A 50 "CHANGELOG.md"
|
||||
```
|
||||
|
||||
## Initial Setup
|
||||
3. Once you're ready to start, checkout and update the changeset release branch:
|
||||
```bash
|
||||
git checkout changeset-release/main
|
||||
git pull origin changeset-release/main
|
||||
```
|
||||
|
||||
## Analyzing Each Change
|
||||
4. For each commit hash in the auto-generated changelog entries:
|
||||
|
||||
a. Find the PR number associated with a commit hash:
|
||||
```bash
|
||||
gh pr list --search "<commit-hash>" --state merged
|
||||
```
|
||||
|
||||
b. Get PR details for better context:
|
||||
```bash
|
||||
gh pr view <PR-number>
|
||||
```
|
||||
|
||||
c. Check if the contributor is external to determine if attribution is needed:
|
||||
```bash
|
||||
# Extract username from PR
|
||||
USERNAME=$(gh pr view <PR-number> --json author --jq .author.login)
|
||||
|
||||
# Check if user is a member of the Cline organization
|
||||
# this command is a bit finnicky, but it 100% works.
|
||||
# if you see a `Error executing command: The command ran successfully, but we couldn't capture its output. Please proceed accordingly.` error, just retry it until you actually get the output
|
||||
# don't make any assumptions, just retry the command to actually get the output and determine if they're external or not.
|
||||
# no output means they are an external contributor, otherwise if there is output they are an internal contributor (part of our github org)
|
||||
gh api "orgs/cline/members" --jq "map(.login)" | grep -i "pashpashpash"
|
||||
```
|
||||
|
||||
d. View the full PR diff to understand code changes:
|
||||
```bash
|
||||
gh pr diff <PR-number> > pr-diff-<PR-number>.txt
|
||||
cat pr-diff-<PR-number>.txt
|
||||
```
|
||||
|
||||
## Updating the Changelog
|
||||
5. Based on PR analysis, update the CHANGELOG.md with user-friendly descriptions:
|
||||
- Use the `<replace_in_file>` tool to edit the CHANGELOG.md file
|
||||
- Group by feature type (Added, Changed, Fixed)
|
||||
- Put most exciting features at the top
|
||||
- Move bug fixes and small improvements to the bottom
|
||||
- Use clear, end-user focused language
|
||||
- For external contributors, add attribution at the end of the relevant entry: `(Thanks @username!)`
|
||||
|
||||
## Version Number Verification
|
||||
6. Confirm the version bump is appropriate:
|
||||
- Check package.json to verify the auto-generated version number:
|
||||
```bash
|
||||
cat package.json | grep "\"version\""
|
||||
```
|
||||
- If the feature set doesn't warrant a minor bump, use the `<replace_in_file>` tool to modify package.json
|
||||
|
||||
7. Ensure the version in CHANGELOG.md has brackets around it:
|
||||
```
|
||||
## [3.16.0]
|
||||
```
|
||||
|
||||
## Creating the Announcement (for minor/major versions only)
|
||||
8. If this is a minor version bump, create/update the announcement component:
|
||||
- Use the `<replace_in_file>` tool to edit the src/views/components/announcement.tsx file
|
||||
- Update the highlights based on key features
|
||||
- Move previous version highlights to the "Previous Updates" section
|
||||
- Use the previous announcement components as reference for structure
|
||||
|
||||
## Finalizing the Release
|
||||
9. Update dependencies with the new version number:
|
||||
```bash
|
||||
npm run install:all
|
||||
```
|
||||
|
||||
10. Commit your changes:
|
||||
```bash
|
||||
git add CHANGELOG.md package.json package-lock.json src/views/components/announcement.tsx
|
||||
git commit -m "Update CHANGELOG.md and announcement for version 3.16.0"
|
||||
```
|
||||
|
||||
11. Push your changes to the changeset branch:
|
||||
```bash
|
||||
git push origin changeset-release/main
|
||||
```
|
||||
|
||||
12. Check that your changes pushed successfully:
|
||||
```bash
|
||||
git status
|
||||
```
|
||||
</detailed_sequence_of_steps>
|
||||
@@ -1,61 +0,0 @@
|
||||
# Git Diff Analysis Workflow
|
||||
|
||||
## Objective
|
||||
Analyze the current branch's changes against main to provide informed insights and context for development decisions.
|
||||
|
||||
## Step 1: Gather Git Information
|
||||
<important>Do not return any text or conversation other than what is necessary to run these commands</important>
|
||||
|
||||
**Run the following command to get the latest changes (bash):**
|
||||
```bash
|
||||
B=$(for c in main master origin/main origin/master; do git rev-parse --verify -q "$c" >/dev/null && echo "$c" && break; done); B=${B:-HEAD}; r(){ git branch --show-current; printf "=== STATUS ===\n"; git status --porcelain | cat; printf "=== COMMIT MESSAGES ===\n"; git log "$B"..HEAD --oneline | cat; printf "=== CHANGED FILES ===\n"; git diff "$B" --name-only | cat; printf "=== FULL DIFF ===\n"; git diff "$B" | cat; }; L=$(r | wc -l); if [ "$L" -gt 500 ]; then r > cline-git-analysis.temp && echo "::OUTPUT_FILE=cline-git-analysis.temp"; else r; fi
|
||||
```
|
||||
|
||||
```powershell
|
||||
$B=$null;foreach($c in 'main','master','origin/main','origin/master'){git rev-parse --verify -q $c *> $null;if($LASTEXITCODE -eq 0){$B=$c;break}};if(-not $B){$B='HEAD'};function r([string]$b){git rev-parse --abbrev-ref HEAD; '=== STATUS ==='; git status --porcelain | cat; '=== COMMIT MESSAGES ==='; git log "$b"..HEAD --oneline | cat; '=== CHANGED FILES ==='; git diff "$b" --name-only | cat; '=== FULL DIFF ==='; git diff "$b" | cat};$out=r $B|Out-String;$lines=($out -split "`r?`n").Count;if($lines -gt 500){$out|Set-Content -NoNewline cline-git-analysis.temp; '::OUTPUT_FILE=cline-git-analysis.temp'}else{$out}
|
||||
```
|
||||
|
||||
## Step 2: Silent, Structured Analysis Phase
|
||||
- Analyze all git output without providing commentary or narration
|
||||
- Read the full diff to understand the scope and nature of changes
|
||||
- Identify patterns, architectural modifications, or potential impacts
|
||||
- Use `read_file` to examine any related files providing additional context on the changes you have observed
|
||||
|
||||
## Step 3: Context Gathering
|
||||
- Analyze related code without providing commentary or narration
|
||||
- Read relevant related source files if needed for complete understanding
|
||||
- Check dependencies, imports, or cross-references spanning the changes
|
||||
- Understand the broader codebase context around modifications
|
||||
- This additional context gathering should include related backend code, as well as related ui/frontend code
|
||||
- You will typically need to analyze at least several files, potentially many, in order to fully complete this step
|
||||
- You should not continue reading additional context if you have exhausted more than 60% of your available context window
|
||||
- If you have exhausted less than 40% of your context window, you should continue reviewing additional context
|
||||
|
||||
## Step 4: Ready for User Interaction
|
||||
**Only after completing the full analysis:**
|
||||
- Engage with the user based on comprehensive understanding
|
||||
- Provide insights about specific modifications and their impacts
|
||||
- If you are certain they exist, note potential breaking changes or compatibility issues
|
||||
- Answer questions with informed context from the complete change set and context gathering
|
||||
- If the user has not provided a question, or the question is insufficient to provide a quality response, ask brief (one sentence) clarifying questions.
|
||||
- Only offer recommendations if they are applicable to the user's request and relevant to the changes that you have observed
|
||||
|
||||
## Key Rules
|
||||
- **No prose or conversation during git research phase**
|
||||
- **No prose or conversation during context gathering phase**
|
||||
- **Complete all analysis before any user interaction**
|
||||
- **Use gathered information for all subsequent questions and insights**
|
||||
- **Focus on understanding the complete picture before discussing**
|
||||
|
||||
## Optional: Additional Analysis Commands
|
||||
For deeper investigation when needed:
|
||||
|
||||
```shell
|
||||
# Detailed commit history with author info
|
||||
git log main..HEAD --format="%h %s (%an)" | cat
|
||||
|
||||
# Change statistics
|
||||
git diff main --stat | cat
|
||||
|
||||
# Specific file type changes
|
||||
git diff main --name-only | grep -E '\.(ts|js|tsx|jsx|py|md)$' | cat
|
||||
@@ -1,354 +0,0 @@
|
||||
You have access to the `gh` terminal command. I already authenticated it for you. Please review it to use the PR that I asked you to review. You're already in the `cline` repo.
|
||||
|
||||
<detailed_sequence_of_steps>
|
||||
# GitHub PR Review Process - Detailed Sequence of Steps
|
||||
|
||||
## 1. Gather PR Information
|
||||
1. Get the PR title, description, and comments:
|
||||
```bash
|
||||
gh pr view <PR-number> --json title,body,comments
|
||||
```
|
||||
|
||||
2. Get the full diff of the PR:
|
||||
```bash
|
||||
gh pr diff <PR-number>
|
||||
```
|
||||
|
||||
## 2. Understand the Context
|
||||
1. Identify which files were modified in the PR:
|
||||
```bash
|
||||
gh pr view <PR-number> --json files
|
||||
```
|
||||
|
||||
2. Examine the original files in the main branch to understand the context:
|
||||
```xml
|
||||
<read_file>
|
||||
<path>path/to/file</path>
|
||||
</read_file>
|
||||
```
|
||||
|
||||
3. For specific sections of a file, you can use search_files:
|
||||
```xml
|
||||
<search_files>
|
||||
<path>path/to/directory</path>
|
||||
<regex>search term</regex>
|
||||
<file_pattern>*.ts</file_pattern>
|
||||
</search_files>
|
||||
```
|
||||
|
||||
## 3. Analyze the Changes
|
||||
1. For each modified file, understand:
|
||||
- What was changed
|
||||
- Why it was changed (based on PR description)
|
||||
- How it affects the codebase
|
||||
- Potential side effects
|
||||
|
||||
2. Look for:
|
||||
- Code quality issues
|
||||
- Potential bugs
|
||||
- Performance implications
|
||||
- Security concerns
|
||||
- Test coverage
|
||||
|
||||
## 4. Ask for User Confirmation
|
||||
1. Before making a decision, ask the user if you should approve the PR, providing your assessment and justification:
|
||||
```xml
|
||||
<ask_followup_question>
|
||||
<question>Based on my review of PR #<PR-number>, I recommend [approving/requesting changes]. Here's my justification:
|
||||
|
||||
[Detailed justification with key points about the PR quality, implementation, and any concerns]
|
||||
|
||||
Would you like me to proceed with this recommendation?</question>
|
||||
<options>["Yes, approve the PR", "Yes, request changes", "No, I'd like to discuss further"]</options>
|
||||
</ask_followup_question>
|
||||
```
|
||||
|
||||
## 5. Ask if User Wants a Comment Drafted
|
||||
1. After the user decides on approval/rejection, ask if they would like a comment drafted:
|
||||
```xml
|
||||
<ask_followup_question>
|
||||
<question>Would you like me to draft a comment for this PR that you can copy and paste?</question>
|
||||
<options>["Yes, please draft a comment", "No, I'll handle the comment myself"]</options>
|
||||
</ask_followup_question>
|
||||
```
|
||||
|
||||
2. If the user wants a comment drafted, provide a well-structured comment they can copy:
|
||||
```
|
||||
Thank you for this PR! Here's my assessment:
|
||||
|
||||
[Detailed assessment with key points about the PR quality, implementation, and any suggestions]
|
||||
|
||||
[Include specific feedback on code quality, functionality, and testing]
|
||||
```
|
||||
|
||||
## 6. Make a Decision
|
||||
1. Approve the PR if it meets quality standards:
|
||||
```bash
|
||||
# For single-line comments:
|
||||
gh pr review <PR-number> --approve --body "Your approval message"
|
||||
|
||||
# For multi-line comments with proper whitespace formatting:
|
||||
cat << EOF | gh pr review <PR-number> --approve --body-file -
|
||||
Thanks @username for this PR! The implementation looks good.
|
||||
|
||||
I particularly like how you've handled X and Y.
|
||||
|
||||
Great work!
|
||||
EOF
|
||||
```
|
||||
|
||||
2. Request changes if improvements are needed:
|
||||
```bash
|
||||
# For single-line comments:
|
||||
gh pr review <PR-number> --request-changes --body "Your feedback message"
|
||||
|
||||
# For multi-line comments with proper whitespace formatting:
|
||||
cat << EOF | gh pr review <PR-number> --request-changes --body-file -
|
||||
Thanks @username for this PR!
|
||||
|
||||
The implementation looks promising, but there are a few things to address:
|
||||
|
||||
1. Issue one
|
||||
2. Issue two
|
||||
|
||||
Please make these changes and we can merge this.
|
||||
EOF
|
||||
```
|
||||
|
||||
Note: The `cat << EOF | ... --body-file -` approach preserves all whitespace and formatting without requiring temporary files. The `-` parameter tells the command to read from standard input.
|
||||
</detailed_sequence_of_steps>
|
||||
|
||||
<example_review_process>
|
||||
# Example PR Review Process
|
||||
|
||||
Let's walk through a real example of reviewing PR #3627 which fixes the thinking mode calculation for Claude 3.7 models.
|
||||
|
||||
## Step 1: Gather PR Information
|
||||
|
||||
```bash
|
||||
# Get PR details
|
||||
gh pr view 3627 --json title,body,comments
|
||||
|
||||
# Get the full diff
|
||||
gh pr diff 3627
|
||||
```
|
||||
|
||||
## Step 2: Understand the Context
|
||||
|
||||
```xml
|
||||
# Examine the original files to understand what's being changed
|
||||
<read_file>
|
||||
<path>src/shared/api.ts</path>
|
||||
</read_file>
|
||||
|
||||
# Look at the ThinkingBudgetSlider component implementation
|
||||
<read_file>
|
||||
<path>webview-ui/src/components/settings/ThinkingBudgetSlider.tsx</path>
|
||||
</read_file>
|
||||
|
||||
# Check how thinking mode is currently handled in API providers
|
||||
<search_files>
|
||||
<path>src/api/providers</path>
|
||||
<regex>reasoningOn</regex>
|
||||
<file_pattern>*.ts</file_pattern>
|
||||
</search_files>
|
||||
```
|
||||
|
||||
## Step 3: Analyze the Changes
|
||||
|
||||
From the PR information and code examination, we can see that:
|
||||
|
||||
1. The PR fixes an issue with the thinking mode budget calculation for Claude 3.7 models
|
||||
2. Currently, the thinking budget is incorrectly calculated as 80% of maxTokens (8192), resulting in 6553 tokens
|
||||
3. Claude 3.7 actually supports a much larger thinking budget (up to 64000 tokens)
|
||||
4. The PR adds a `thinkingConfig` property with `maxBudget: 64000` to model definitions
|
||||
5. It updates API handlers to use this value when reasoning mode is enabled
|
||||
6. It enhances the slider component to calculate max values based on model-specific percentages
|
||||
7. Comprehensive tests were added to verify the calculations
|
||||
|
||||
## Step 4: Ask for User Confirmation
|
||||
|
||||
```xml
|
||||
<ask_followup_question>
|
||||
<question>Based on my review of PR #3627, I recommend approving this PR. Here's my justification:
|
||||
|
||||
1. The PR correctly fixes the thinking mode budget calculation for Claude 3.7 models
|
||||
2. It properly implements the thinkingConfig.maxBudget property (64000) for Claude 3.7 models
|
||||
3. It applies the appropriate percentage (50%) for the slider calculation
|
||||
4. The changes are well-tested with comprehensive unit tests
|
||||
5. The implementation is clean and follows the project's coding standards
|
||||
6. The PR includes helpful screenshots demonstrating the fix
|
||||
|
||||
Would you like me to proceed with approving this PR?</question>
|
||||
<options>["Yes, approve the PR", "No, I'd like to discuss further", "Let me review it myself first"]</options>
|
||||
</ask_followup_question>
|
||||
```
|
||||
|
||||
## Step 5: Ask if User Wants a Comment Drafted
|
||||
|
||||
```xml
|
||||
<ask_followup_question>
|
||||
<question>Would you like me to draft a comment for this PR that you can copy and paste?</question>
|
||||
<options>["Yes, please draft a comment", "No, I'll handle the comment myself"]</options>
|
||||
</ask_followup_question>
|
||||
```
|
||||
|
||||
## Step 6: Make a Decision
|
||||
|
||||
```bash
|
||||
# Option 1: Simple one-line comment
|
||||
gh pr review 3627 --approve --body "This PR looks good! It correctly fixes the thinking mode budget calculation for Claude 3.7 models."
|
||||
|
||||
# Option 2: Multi-line comment with proper whitespace formatting
|
||||
cat << EOF | gh pr review 3627 --approve --body-file -
|
||||
This PR looks good! It correctly fixes the thinking mode budget calculation for Claude 3.7 models.
|
||||
|
||||
I particularly like:
|
||||
1. The proper implementation of thinkingConfig.maxBudget property (64000)
|
||||
2. The appropriate percentage (50%) for the slider calculation
|
||||
3. The comprehensive unit tests
|
||||
4. The clean implementation that follows project coding standards
|
||||
|
||||
Great work!
|
||||
EOF
|
||||
```
|
||||
</example_review_process>
|
||||
|
||||
<common_gh_commands>
|
||||
# Common GitHub CLI Commands for PR Review
|
||||
|
||||
## Basic PR Commands
|
||||
```bash
|
||||
# Get current PR number
|
||||
gh pr view --json number -q .number
|
||||
|
||||
# List open PRs
|
||||
gh pr list
|
||||
|
||||
# View a specific PR
|
||||
gh pr view <PR-number>
|
||||
|
||||
# View PR with specific fields
|
||||
gh pr view <PR-number> --json title,body,comments,files,commits
|
||||
|
||||
# Check PR status
|
||||
gh pr status
|
||||
```
|
||||
|
||||
## Diff and File Commands
|
||||
```bash
|
||||
# Get the full diff of a PR
|
||||
gh pr diff <PR-number>
|
||||
|
||||
# List files changed in a PR
|
||||
gh pr view <PR-number> --json files
|
||||
|
||||
# Check out a PR locally
|
||||
gh pr checkout <PR-number>
|
||||
```
|
||||
|
||||
## Review Commands
|
||||
```bash
|
||||
# Approve a PR (single-line comment)
|
||||
gh pr review <PR-number> --approve --body "Your approval message"
|
||||
|
||||
# Approve a PR (multi-line comment with proper whitespace)
|
||||
cat << EOF | gh pr review <PR-number> --approve --body-file -
|
||||
Your multi-line
|
||||
approval message with
|
||||
|
||||
proper whitespace formatting
|
||||
EOF
|
||||
|
||||
# Request changes on a PR (single-line comment)
|
||||
gh pr review <PR-number> --request-changes --body "Your feedback message"
|
||||
|
||||
# Request changes on a PR (multi-line comment with proper whitespace)
|
||||
cat << EOF | gh pr review <PR-number> --request-changes --body-file -
|
||||
Your multi-line
|
||||
change request with
|
||||
|
||||
proper whitespace formatting
|
||||
EOF
|
||||
|
||||
# Add a comment review (without approval/rejection)
|
||||
gh pr review <PR-number> --comment --body "Your comment message"
|
||||
|
||||
# Add a comment review with proper whitespace
|
||||
cat << EOF | gh pr review <PR-number> --comment --body-file -
|
||||
Your multi-line
|
||||
comment with
|
||||
|
||||
proper whitespace formatting
|
||||
EOF
|
||||
```
|
||||
|
||||
## Additional Commands
|
||||
```bash
|
||||
# View PR checks status
|
||||
gh pr checks <PR-number>
|
||||
|
||||
# View PR commits
|
||||
gh pr view <PR-number> --json commits
|
||||
|
||||
# Merge a PR (if you have permission)
|
||||
gh pr merge <PR-number> --merge
|
||||
```
|
||||
</common_gh_commands>
|
||||
|
||||
<general_guidelines_for_commenting>
|
||||
When reviewing a PR, please talk normally and like a friendly reviwer. You should keep it short, and start out by thanking the author of the pr and @ mentioning them.
|
||||
|
||||
Whether or not you approve the PR, you should then give a quick summary of the changes without being too verbose or definitive, staying humble like that this is your understanding of the changes. Kind of how I'm talking to you right now.
|
||||
|
||||
If you have any suggestions, or things that need to be changed, request changes instead of approving the PR.
|
||||
|
||||
Leaving inline comments in code is good, but only do so if you have something specific to say about the code. And make sure you leave those comments first, and then request changes in the PR with a short comment explaining the overall theme of what you're asking them to change.
|
||||
</general_guidelines_for_commenting>
|
||||
|
||||
<example_comments_that_i_have_written_before>
|
||||
<brief_approve_comment>
|
||||
Looks good, though we should make this generic for all providers & models at some point
|
||||
</brief_approve_comment>
|
||||
<brief_approve_comment>
|
||||
Will this work for models that may not match across OR/Gemini? Like the thinking models?
|
||||
</brief_approve_comment>
|
||||
<approve_comment>
|
||||
This looks great! I like how you've handled the global endpoint support - adding it to the ModelInfo interface makes total sense since it's just another capability flag, similar to how we handle other model features.
|
||||
|
||||
The filtered model list approach is clean and will be easier to maintain than hardcoding which models work with global endpoints. And bumping the genai library was obviously needed for this to work.
|
||||
|
||||
Thanks for adding the docs about the limitations too - good for users to know they can't use context caches with global endpoints but might get fewer 429 errors.
|
||||
</approve_comment>
|
||||
<requesst_changes_comment>
|
||||
This is awesome. Thanks @scottsus.
|
||||
|
||||
My main concern though - does this work for all the possible VS Code themes? We struggled with this initially which is why it's not super styled currently. Please test and share screenshots with the different themes to make sure before we can merge
|
||||
</request_changes_comment>
|
||||
<request_changes_comment>
|
||||
Hey, the PR looks good overall but I'm concerned about removing those timeouts. Those were probably there for a reason - VSCode's UI can be finicky with timing.
|
||||
|
||||
Could you add back the timeouts after focusing the sidebar? Something like:
|
||||
|
||||
```typescript
|
||||
await vscode.commands.executeCommand("claude-dev.SidebarProvider.focus")
|
||||
await setTimeoutPromise(100) // Give UI time to update
|
||||
visibleWebview = WebviewProvider.getSidebarInstance()
|
||||
```
|
||||
</request_changes_comment>
|
||||
<request_changes_comment>
|
||||
Heya @alejandropta thanks for working on this!
|
||||
|
||||
A few notes:
|
||||
1 - Adding additional info to the environment variables is fairly problematic because env variables get appended to **every single message**. I don't think this is justifiable for a somewhat niche use case.
|
||||
2 - Adding this option to settings to include that could be an option, but we want our options to be simple and straightforward for new users
|
||||
3 - We're working on revisualizing the way our settings page is displayed/organized, and this could potentially be reconciled once that is in and our settings page is more clearly delineated.
|
||||
|
||||
So until the settings page is update, and this is added to settings in a way that's clean and doesn't confuse new users, I don't think we can merge this. Please bear with us.
|
||||
</request_changes_comment>
|
||||
<request_changes_comment>
|
||||
Also, don't forget to add a changeset since this fixes a user-facing bug.
|
||||
|
||||
The architectural change is solid - moving the focus logic to the command handlers makes sense. Just don't want to introduce subtle timing issues by removing those timeouts.
|
||||
</request_changes_comment>
|
||||
</example_comments_that_i_have_written_before>
|
||||
@@ -1,392 +0,0 @@
|
||||
# General writing guide
|
||||
|
||||
# How I want you to write
|
||||
|
||||
I'm gonna write something technical.
|
||||
|
||||
It's often less about the nitty-gritty details of the tech stuff and more about learning something new or getting a solution handed to me on a silver platter.
|
||||
|
||||
Look, when I read, I want something out of it. So when I write, I gotta remember that my readers want something too. This whole piece? It's about cluing in anyone who writes for me, or wants me to write for them, on how I see this whole writing product thing.
|
||||
|
||||
I'm gonna lay out a checklist of stuff I'd like to have. It'll make the whole writing gig a bit smoother, you know?
|
||||
|
||||
## Crafting Compelling Titles
|
||||
|
||||
I often come across titles like "How to do X with Y,Z technology." These don't excite me because X or Y are usually unfamiliar unless they're already well-known. Its rarely the dream to use X unless X is the dream.
|
||||
|
||||
My dream isn’t to use instructor, its to do something valueble with the data it extracts
|
||||
|
||||
An effective title should:
|
||||
|
||||
- Evoke an emotional response
|
||||
- Highlight someone's goal
|
||||
- Offer a dream or aspiration
|
||||
- Challenge or comment on a belief
|
||||
- Address someone's problems
|
||||
|
||||
I believe it's more impactful to write about specific problems. If this approach works, you can replicate it across various scenarios rather than staying too general.
|
||||
|
||||
- Time management for everyone can be a 15$ ebook
|
||||
- Time management for executives is a 2000$ workshop
|
||||
|
||||
Aim for titles that answer questions you think everyone is asking, or address thoughts people have but can't quite articulate.
|
||||
|
||||
Instead of "How I do something" or "How to do something," frame it from the reader's perspective with "How you can do something." This makes the title more engaging. Just make sure the difference is advisory if the content is subjective. “How I made a million dollars” might be more reasonable than “How to make a million dollars” since you are the subject and the goal might be to share your story in hopes of helping others.
|
||||
|
||||
This approach ultimately trains the reader to have a stronger emotional connection to your content.
|
||||
|
||||
- "How I do X"
|
||||
- "How You Can do X"
|
||||
|
||||
Between these two titles, it's obvious which one resonates more emotionally.
|
||||
|
||||
You can take it further by adding specific conditions. For instance, you could target a particular audience or set a timeframe:
|
||||
|
||||
- How to set up Braintrust
|
||||
- How to set up Braintrust in 5 minutes
|
||||
|
||||
## NO adjectiives
|
||||
|
||||
I want you to almost always avoid adjectives and try to use evidence instead. Instead of saying "production ready," you can write something like "scaling this to 100 servers or 1 million documents per second." Numbers like that will tell you exactly what the specificity of your product is. If you have to use adjectives rather than evidence, you are probably making something up.
|
||||
|
||||
There's no reason to say something like "blazingly fast" unless those things are already known phrases.
|
||||
|
||||
Instead, say "200 times faster" or "30% faster." A 30% improvement in recommendation system speed is insane.
|
||||
|
||||
There's a 200 times performance improvement because we went from one programming language to another. It's just something that's a little bit more expected and understandable.
|
||||
|
||||
Another test that I really like using recently is tracking whether or not the statements you make can be:
|
||||
|
||||
- Visualized
|
||||
- Proven false
|
||||
- Said only by you
|
||||
|
||||
If you can nail all three, the claim you make will be more likely to resonate with an audience because only you can say it.
|
||||
|
||||
Earlier this year, I had an example where I embedded all of Wikipedia in 17 minutes with 20 bucks, and it got half a million views. All we posted was a video of me kicking off the job, and then you can see all the log lines go through. You see the number of containers go from 1 out of 50 to 50 out of 50.
|
||||
|
||||
It was easy to visualize and could have been proven false by being unreproducible. Lastly, Modal is the only company that could do that in such an effortless way, which made it unique.
|
||||
|
||||
## Keep It Digestible
|
||||
- Aim for 5-minute reads
|
||||
- Write at a Grade 10 reading level
|
||||
- Break up long paragraphs
|
||||
- Use headers and bullet points
|
||||
|
||||
## Make It Scannable
|
||||
- Bold key points
|
||||
- Use subheadings every 3-4 paragraphs
|
||||
- Include plenty of white space
|
||||
- Add relevant examples
|
||||
|
||||
This structure works whether you're writing a tweet thread or a full blog post. The key is making complex ideas accessible.
|
||||
|
||||
# Guide to Writing Cline Documentation
|
||||
|
||||
## Some general principles for explaining features
|
||||
|
||||
If you're talking about a feature, it's helpful to start with a human-readable explanations that cover what the feature is in simple terms. Skip jargon and explain it like you're talking to someone who's never seen it before. This sets the foundation for everything that follows.
|
||||
|
||||
Combine location and usage into one flowing section. Tell users exactly where to find the feature and how to use it, but weave the instructions into natural prose with a good balance of bullet points, numbered lists, code examples (if applicable), mintlify components, and headers/subheaders. Users shouldn't have to jump between separate "where is it" and "how do I use it" sections.
|
||||
|
||||
Show the feature in action with real examples like actual files, workflows, or code. Users need to see concrete implementations, not just abstract descriptions. This is where understanding turns into practical knowledge.
|
||||
|
||||
When talking about a feature, include an inspiration section that sparks imagination. This section pushes people from understanding to action by showing them what becomes possible when they use this feature creatively. It's what separates good documentation from great documentation.
|
||||
|
||||
## Writing Principles That Actually Work
|
||||
|
||||
### Write for Action, Not Just Understanding
|
||||
|
||||
Documentation should motivate users to try things. Instead of just explaining how something works, focus on what users can accomplish with it. The inspiration section is crucial - it's what transforms passive readers into active users.
|
||||
|
||||
### Create a Natural Story Flow
|
||||
|
||||
It should feel like a conversation that naturally progresses from "what is this?" to "how do I use it?" to "here's a real example" to "imagine what you could do with this."
|
||||
|
||||
### Show Real Examples, Not Toy Demos
|
||||
|
||||
Provide actual workflow files, real code snippets, and concrete implementations that users can copy and adapt. Abstract examples don't help anyone - users want to see exactly what they'll be working with.
|
||||
|
||||
### Keep It Scannable But Not Fragmented
|
||||
|
||||
Write in prose that flows naturally when read completely, but structure it so users can quickly find specific information when they're troubleshooting. Avoid dense walls of text, but also avoid over-formatting with excessive bullet points and bold headers. There should be a nice visual heirarchy of balance between all elements, so you can quickly scan the page and find what you're looking for.
|
||||
|
||||
## Language and Tone Guidelines
|
||||
|
||||
Write clearly without dumbing things down. Use simple language when possible, but don't avoid technical terms that users need to know. Explain concepts in terms of what users can achieve rather than how the software works internally.
|
||||
|
||||
Make your writing conversational and encouraging. Phrases like "you can also try" or "when that works" feel more natural than rigid instructional language. Help users feel confident about trying new things.
|
||||
|
||||
Keep content concise and purposeful. Every sentence should either help users understand something or help them do something. If it doesn't serve one of those purposes, cut it.
|
||||
|
||||
Build in context and reasoning. Users want to understand why they're doing something, not just what to do. This builds confidence and helps them troubleshoot when things don't work exactly as expected.
|
||||
|
||||
## Practical Implementation
|
||||
|
||||
Structure each feature page consistently with the four-section approach, but let the content flow naturally within that structure. Use visual assets like videos and screenshots to complement the written content - they often communicate more effectively than paragraphs of description.
|
||||
|
||||
Link generously to related resources, examples, and deeper documentation. Users should never feel stuck or wonder where to go next. Maintain a repository of real examples that users can reference and adapt to their own needs.
|
||||
|
||||
The goal is documentation that feels more like helpful guidance from an experienced colleague than a technical manual. Users should finish reading feeling excited about what they can accomplish, not just informed about what the feature does.
|
||||
|
||||
## Balance Structure with Flexibility
|
||||
|
||||
While they discuss having consistent documentation structure, there's also mention of making content feel less rigid and more natural. The writing should follow guidelines while still feeling conversational and engaging.
|
||||
|
||||
## Bad examples
|
||||
|
||||
I personally hate this pattern of bullet point **Bold Text** colon and then more text:
|
||||
<bad_example_of_writing>
|
||||
#### macOS
|
||||
|
||||
1. **Switch to bash**: Go to Cline Settings → Terminal → Default Terminal Profile → Select "bash"
|
||||
2. **Disable Oh-My-Zsh temporarily**: If using zsh, try `mv ~/.zshrc ~/.zshrc.backup` and restart VSCode
|
||||
3. **Set environment**: Add to your shell config: `export TERM=xterm-256color`
|
||||
|
||||
#### Windows
|
||||
|
||||
1. **Use PowerShell 7**: Install from Microsoft Store, then select it in Cline settings
|
||||
2. **Disable Windows ConPTY**: VSCode Settings → Terminal › Integrated: Windows Enable Conpty → Uncheck
|
||||
3. **Try Command Prompt**: Sometimes simpler is better - switch to cmd.exe
|
||||
|
||||
#### Linux
|
||||
|
||||
1. **Use bash**: Most reliable option - select in Cline settings
|
||||
2. **Check permissions**: Ensure VSCode has terminal access permissions
|
||||
3. **Disable custom prompts**: Comment out prompt customizations in `.bashrc`
|
||||
|
||||
</bad_example_of_writing>
|
||||
|
||||
We should instead strive to write beautiful docs that read well. We can use bullet points and numbered lists but it should read naturally and be delightful to look at hierachally when scanning through the doc. There should be a good balance between blocks of text, code snippets, paragraphs, numbered lists, and bullet points. When scanning the documentation visually, you should feel like you're adminiring a tasteful art piece.
|
||||
|
||||
<good_example_of_writing>
|
||||
#### macOS
|
||||
|
||||
The most common fix is switching to bash. Navigate to Cline Settings → Terminal → Default Terminal Profile and select "bash" from the dropdown.
|
||||
|
||||
If you're still having issues, Oh-My-Zsh might be interfering with terminal integration. Try temporarily disabling it:
|
||||
- Run `mv ~/.zshrc ~/.zshrc.backup`
|
||||
- Restart VSCode
|
||||
|
||||
You can also add `export TERM=xterm-256color` to your shell configuration file to improve compatibility.
|
||||
|
||||
#### Windows
|
||||
|
||||
PowerShell 7 provides the most reliable experience. Install it from the Microsoft Store, then select it in your Cline settings.
|
||||
|
||||
Still seeing problems? Try these solutions:
|
||||
- Disable Windows ConPTY: VSCode Settings → Terminal › Integrated: Windows Enable Conpty → uncheck
|
||||
- Switch to Command Prompt (cmd.exe) - sometimes simpler shells work better
|
||||
|
||||
#### Linux
|
||||
|
||||
Bash is your most dependable option. Select it in Cline settings if you haven't already.
|
||||
|
||||
Check these common issues:
|
||||
- Ensure VSCode has terminal access permissions
|
||||
- Temporarily comment out custom prompt configurations in your `.bashrc`
|
||||
</good_example_of_writing>
|
||||
|
||||
This is much more natural to read. Writing this way creates a conversational flow, and bullet points are used idiomatically.
|
||||
|
||||
# Using Mintlify Components Idiomatically
|
||||
|
||||
Mintlify's custom components can transform basic documentation into engaging, scannable content that users actually want to read. Here's how to use them effectively.
|
||||
|
||||
## Visual Content with Frames
|
||||
|
||||
Videos and images should be wrapped in `<Frame>` components rather than using raw HTML or markdown. This creates consistent styling and proper responsive behavior.
|
||||
|
||||
For videos, embed them directly rather than linking externally. Users are much more likely to watch a 30-second demonstration than click through to another platform:
|
||||
|
||||
```jsx
|
||||
<Frame>
|
||||
<iframe
|
||||
style={{ width: "100%", aspectRatio: "16/9" }}
|
||||
src="https://www.youtube.com/embed/your-video-id"
|
||||
title="Feature demonstration"
|
||||
frameBorder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowFullScreen
|
||||
/>
|
||||
</Frame>
|
||||
```
|
||||
|
||||
Screenshots work similarly - the frame provides visual polish and consistency:
|
||||
|
||||
```jsx
|
||||
<Frame>
|
||||
<img src="/path/to/screenshot.png" alt="Descriptive alt text" />
|
||||
</Frame>
|
||||
```
|
||||
|
||||
## Cards for Navigation and Overview
|
||||
|
||||
Cards excel at creating scannable overviews that link to detailed documentation. They're perfect for feature listings, getting started guides, or any section where users need to choose their path.
|
||||
|
||||
Use the two-column layout for related features:
|
||||
|
||||
```jsx
|
||||
<Columns cols={2}>
|
||||
<Card title="Feature Name" icon="relevant-icon" href="/link/to/docs">
|
||||
Brief description that explains what this feature does and why someone would use it.
|
||||
</Card>
|
||||
|
||||
<Card title="Related Feature" icon="another-icon" href="/another/link">
|
||||
Another concise explanation that helps users understand the value proposition.
|
||||
</Card>
|
||||
</Columns>
|
||||
```
|
||||
|
||||
The key is writing card descriptions that are informative enough to help users decide whether to click through, but concise enough to scan quickly. Each card should answer "what does this do?" and "why would I need this?"
|
||||
|
||||
## Tips and Notes for Context
|
||||
|
||||
Use `<Tip>` components for helpful information that enhances the main content without cluttering it:
|
||||
|
||||
```jsx
|
||||
<Tip>
|
||||
Pro tip: You can combine multiple @ mentions in a single message to give Cline
|
||||
comprehensive context about your issue.
|
||||
</Tip>
|
||||
```
|
||||
|
||||
`<Note>` components work well for important caveats or technical limitations:
|
||||
|
||||
```jsx
|
||||
<Note>
|
||||
Due to VS Code limitations, some features require specific settings to work properly.
|
||||
</Note>
|
||||
```
|
||||
|
||||
`<Info>` is also cool:
|
||||
|
||||
<Info>
|
||||
**Quick Fix**: If you're experiencing terminal issues, try switching to a simpler shell like `bash` in the Cline settings.
|
||||
This resolves 90% of terminal integration problems.
|
||||
</Info>
|
||||
|
||||
**Never** fall into that awful **Bold Text** - description pattern that we specifically identified as bad writing. The content should flow naturally as connected thoughts rather than feeling like a templated AI response with forced formatting.
|
||||
|
||||
|
||||
## When to Use Bullet Points and Numbered Lists Strategically
|
||||
|
||||
Bullet points serve functional purposes - use them for:
|
||||
|
||||
**Sequential actions or troubleshooting steps** where users need to follow a specific order:
|
||||
1. Install the extension
|
||||
2. Restart VSCode
|
||||
3. Check the settings panel
|
||||
|
||||
**Lists of related options** where users need to choose one approach:
|
||||
- Try PowerShell 7 for the most reliable experience
|
||||
- Switch to Command Prompt if you're still having issues
|
||||
- Use WSL Bash for Linux compatibility
|
||||
|
||||
**Quick reference items** that users might need to scan quickly when problem-solving.
|
||||
|
||||
**Improving Visual Hierarchy** when there's a wall of text - that's a good time to introduce bullet points or numbered lists.
|
||||
|
||||
Each bulleted item or numbered list should be a discrete action or piece of information that benefits from being visually separated. This is a key weapon you can employ when going for that artwork experience I mentioned earlier.
|
||||
|
||||
<good_example_of_bullet_points>
|
||||
## Finding and Configuring Terminal Settings
|
||||
|
||||
You can access Cline's terminal settings by clicking the settings icon in the Cline sidebar, then navigating to the Terminal section. These settings control how Cline interacts with your system's terminal.
|
||||
|
||||
- The **Default Terminal Profile** setting determines which shell Cline uses for executing commands. If you're experiencing issues, this is usually the first thing to change. I personally keep this set to `bash` on all my systems because it's the most reliable option, even though I use `zsh` for my regular terminal work.
|
||||
|
||||
- **Shell Integration Timeout** controls how long Cline waits for the terminal to become ready. The default is 4 seconds, but if you have a heavy shell configuration (lots of plugins, slow startup scripts), you might need to increase this to 10 or even 15 seconds. I've found that WSL environments and SSH connections often need longer timeouts.
|
||||
|
||||
- The **Enable Aggressive Terminal Reuse** setting determines whether Cline tries to reuse existing terminals even when they're not in the correct directory. When this causes problems (commands running in the wrong directory, virtual environment issues), disabling it creates more terminal instances but ensures each command runs in a clean state.
|
||||
|
||||
- **Terminal Output Line Limit** sets how many lines of output Cline will read from commands. The default of 500 lines works for most cases, but you might need to adjust it. For verbose build outputs, increase it. For commands with progress bars that spam thousands of lines, decrease it to around 100 to avoid consuming too many tokens.
|
||||
</good_example_of_bullet_points>
|
||||
|
||||
## Write Like a Human, Not an AI
|
||||
|
||||
Keep it short and direct. If you can say something in fewer words, do it. Long explanations often confuse more than they help.
|
||||
|
||||
Use normal sentence lengths. Mix short and long sentences naturally, like you would when talking to someone. Avoid those meandering compound sentences that go on forever.
|
||||
|
||||
Cut the corporate speak. Instead of "utilize," say "use." Instead of "in order to," just say "to." Write like you're explaining something to a colleague, not drafting a press release.
|
||||
|
||||
Don't over-explain obvious things. If users are reading documentation, they probably understand basic concepts. Respect their intelligence.
|
||||
|
||||
## Never use em dashes or emojis
|
||||
|
||||
That's pretty much it. Never use them. Only AI writes with em dashes or emojis.
|
||||
|
||||
# Anthropomorphizing Cline
|
||||
|
||||
When referring to Cline, always call him a "him" not an "it".
|
||||
|
||||
Bad example:
|
||||
- When Cline can’t execute commands or read their output, you lose access to one of its most powerful capabilities.
|
||||
|
||||
Good Example:
|
||||
|
||||
- When Cline can’t execute commands or read their output, you lose access to one of his most powerful capabilities.
|
||||
|
||||
# Using "I" when sharing your workflow
|
||||
|
||||
Adding a personal touch goes a long way. There are great examples in the docs currently where I use "I" to share how I personally use cline, from dev to dev. It's a great technique.
|
||||
|
||||
# Crosslinking relevant documentation pages
|
||||
|
||||
Make sure you crosslink when you're done writing the docs. If there are relevant docs, just link to them.
|
||||
|
||||
# Brevity is the soul of wit
|
||||
|
||||
Don't ramble if you don't need to. Use bullet points and numbered lists. Keep things easy to read.
|
||||
|
||||
<bad_example>
|
||||
|
||||
When Cline can't execute commands or read their output, you lose access to one of his most powerful capabilities. Terminal integration problems are frustrating, but they're usually fixable with a few simple changes.
|
||||
|
||||
## The Most Common Problem: Shell Integration Issues
|
||||
|
||||
If you're seeing "Shell integration unavailable" or Cline isn't getting command output, the issue is almost always your shell configuration. Complex shell setups with custom prompts, plugins, and fancy configurations can interfere with VSCode's terminal integration.
|
||||
|
||||
**Switch to bash first.** This fixes the problem 90% of the time. Navigate to Cline Settings → Terminal → Default Terminal Profile and select "bash" from the dropdown. Restart VSCode after making this change.
|
||||
|
||||
Still having issues? Try increasing the shell integration timeout. Go to Cline Settings → Terminal → Shell Integration Timeout and change it from 4 seconds to 10 seconds. Heavy shell configurations need more time to initialize properly.
|
||||
|
||||
If commands are running in the wrong directories or you're seeing weird behavior, disable aggressive terminal reuse. In Cline Settings → Terminal, uncheck "Enable aggressive terminal reuse." This creates more terminal instances but ensures each command runs in a clean environment.
|
||||
|
||||
|
||||
</bad_exaxmple>
|
||||
|
||||
The first part is total filler, useless to any serious developer. You can tell it's written by a non technical person that doesn't value clean, straightforward information.
|
||||
|
||||
<good_example>
|
||||
## Shell Integration Issues
|
||||
|
||||
If you're seeing "Shell integration unavailable" or Cline can't read command output, your shell configuration is interfering with VSCode's terminal integration.
|
||||
|
||||
**Switch to bash first.** Go to Cline Settings → Terminal → Default Terminal Profile and select "bash." This fixes 90% of problems.
|
||||
|
||||
Still broken? Try these:
|
||||
- Increase shell integration timeout to 10 seconds in Cline Settings → Terminal
|
||||
- Disable "aggressive terminal reuse" if commands run in wrong directories
|
||||
- Restart VSCode after making changes
|
||||
</good_example>
|
||||
|
||||
The good version cuts straight to the problem and solution. No hand-holding, no emotional language about frustration, just the facts: what's wrong, how to fix it, what to try next. Respects that developers want information, not sympathy.RetryClaude can make mistakes. Please double-check responses.
|
||||
|
||||
ALWAYS consider your audience. And your audience is devs who don't want their time wasted. Give them the info. I cannot stress this enough. Use bullet points and numbered lists. Prose is good, but every word should actually mean something to the dev reading it.
|
||||
|
||||
# Lastly, before you start writing docs
|
||||
|
||||
1. Internalize these guidelines. I mean it.
|
||||
|
||||
2. Read `docs/docs.json` and get an understanding of the structure of the docs. This will come in handly at the end when you're doing a final pass so you can cross link to docs where relevant.
|
||||
|
||||
3. Read some good examples that I personally wrote and am proud of:
|
||||
|
||||
- docs/features/slash-commands/workflows.mdx
|
||||
- docs/features/slash-commands/new-task.mdx
|
||||
- docs/features/at-mentions/overview.mdx
|
||||
- docs/features/drag-and-drop.mdx
|
||||
|
||||
4. If the user specifies any other instructions make sure you follow them.
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"root": true,
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 6,
|
||||
"sourceType": "module"
|
||||
},
|
||||
"plugins": ["@typescript-eslint"],
|
||||
"rules": {
|
||||
"@typescript-eslint/naming-convention": [
|
||||
"warn",
|
||||
{
|
||||
"selector": "import",
|
||||
"format": ["camelCase", "PascalCase"]
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/semi": "off",
|
||||
"curly": "warn",
|
||||
"eqeqeq": "warn",
|
||||
"no-throw-literal": "warn",
|
||||
"semi": "off",
|
||||
"react-hooks/exhaustive-deps": "off"
|
||||
},
|
||||
"ignorePatterns": ["out", "dist", "**/*.d.ts"]
|
||||
}
|
||||
@@ -1,4 +1,2 @@
|
||||
demo.gif filter=lfs diff=lfs merge=lfs -text
|
||||
assets/docs/demo.gif filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
* text=auto eol=lf
|
||||
|
||||
+1
-3
@@ -1,3 +1 @@
|
||||
/docs/
|
||||
/.github/ @saoudrizwan @garoth @sjf
|
||||
/README.md @saoudrizwan @nickbaumann98
|
||||
* @saoudrizwan @ocasta181 @NightTrek @pashpashpash @dcbartlett
|
||||
|
||||
@@ -1,69 +1,54 @@
|
||||
name: 🐛 Bug Report
|
||||
description: File a bug report
|
||||
labels: ['bug']
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**Important:** All bug reports must be reproducible using Claude Sonnet 4.5. Cline uses complex prompts so less capable models may not work as expected.
|
||||
- type: dropdown
|
||||
id: plugin-type
|
||||
attributes:
|
||||
label: Plugin Type
|
||||
description: Which plugin are you reporting a bug for?
|
||||
options:
|
||||
- VSCode Extension
|
||||
- JetBrains Plugin
|
||||
default: 0
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: cline-version
|
||||
attributes:
|
||||
label: Cline Version
|
||||
description: What version of Cline are you using? (You can find this at the bottom of the Settings view)
|
||||
placeholder: 'e.g., 1.2.3'
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: What happened?
|
||||
description: Also tell us, what did you expect to happen?
|
||||
placeholder: Tell us what you see!
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: How do you trigger this bug? Please walk us through it step by step.
|
||||
value: |
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
validations:
|
||||
required: false
|
||||
- type: input
|
||||
id: provider-model
|
||||
attributes:
|
||||
label: Provider/Model
|
||||
description: What provider and model were you using when the issue occurred?
|
||||
placeholder: 'e.g., cline:anthropic/claude-sonnet-4.5, gemini:gemini-2.5-pro-exp-03-25'
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: system-info
|
||||
attributes:
|
||||
label: System Information
|
||||
description: What operating system and hardware are you using?
|
||||
placeholder: |
|
||||
Operating System: Windows 11, macOS Sonoma, Ubuntu 22.04, etc.
|
||||
Hardware: CPU, GPU, RAM specifications if relevant
|
||||
e.g.,
|
||||
OS: Windows 11
|
||||
CPU: Intel Core i7-11700K
|
||||
GPU: NVIDIA GeForce RTX 3070
|
||||
RAM: 32GB DDR4
|
||||
validations:
|
||||
required: false
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**Important:** All bug reports must be reproducible using Claude 3.5 Sonnet. Cline uses complex prompts so less capable models may not work as expected.
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: What happened?
|
||||
description: Also tell us, what did you expect to happen?
|
||||
placeholder: Tell us what you see!
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: How do you trigger this bug? Please walk us through it step by step.
|
||||
value: |
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant API REQUEST output
|
||||
description: Please copy and paste any relevant output. This will be automatically formatted into code, so no need for backticks.
|
||||
render: shell
|
||||
- type: input
|
||||
id: operating-system
|
||||
attributes:
|
||||
label: Operating System
|
||||
description: What operating system are you using?
|
||||
placeholder: "e.g., Windows 11, macOS Sonoma, Ubuntu 22.04"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: cline-version
|
||||
attributes:
|
||||
label: Cline Version
|
||||
description: What version of Cline are you using? (You can find this at the bottom of the Settings view)
|
||||
placeholder: "e.g., 1.2.3"
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add any other context about the problem here, such as screenshots or related issues.
|
||||
|
||||
@@ -6,3 +6,6 @@ contact_links:
|
||||
- name: 👋 Cline Discord
|
||||
url: https://discord.gg/cline
|
||||
about: Join our Discord community for discussions and support
|
||||
- name: ❓ Other Questions?
|
||||
url: https://x.com/sdrzn
|
||||
about: Contact the developer on X @sdrzn for other inquiries
|
||||
|
||||
@@ -1,46 +1,10 @@
|
||||
<!--
|
||||
Thank you for contributing to Cline!
|
||||
|
||||
⚠️ Important: Before submitting this PR, please ensure you have:
|
||||
- For feature requests: Created a discussion in our Feature Requests discussions board https://github.com/cline/cline/discussions/categories/feature-requests and received approval from core maintainers before implementation
|
||||
- For all changes: Link the associated issue/discussion in the "Related Issue" section below
|
||||
|
||||
Limited exceptions:
|
||||
Small bug fixes, typo corrections, minor wording improvements, or simple type fixes that don't change functionality may be submitted directly without prior discussion.
|
||||
|
||||
Why this requirement?
|
||||
We deeply appreciate all community contributions - they are essential to Cline's success! To ensure the best use of everyone's time and maintain project direction, we use our Feature Requests discussions board to gauge community interest and validate feature ideas before implementation begins. This helps us focus development efforts on features that will benefit the most users.
|
||||
-->
|
||||
|
||||
### Related Issue
|
||||
|
||||
<!-- Replace XXXX with the issue number that this PR addresses -->
|
||||
**Issue:** #XXXX
|
||||
|
||||
### Description
|
||||
|
||||
<!--
|
||||
Help reviewers understand your changes by making this PR readable and well-organized:
|
||||
|
||||
- What problem does this PR solve?
|
||||
- Why were these changes introduced and what purpose do they serve?
|
||||
- For larger changes, provide context about your approach and reasoning
|
||||
|
||||
Small PRs may need minimal description, but larger changes benefit from explaining where you're coming from. Much of this context can be in the linked issue above, so feel free to reference it rather than repeating everything here.
|
||||
-->
|
||||
<!-- Describe your changes in detail. What problem does this PR solve? -->
|
||||
|
||||
### Test Procedure
|
||||
|
||||
<!--
|
||||
Please walk us through your testing approach and thought process. This helps reviewers understand that you've thoroughly considered the impact of your changes:
|
||||
|
||||
- How did you test this change?
|
||||
- What could potentially break and how did you verify it doesn't?
|
||||
- What existing functionality might be affected and how did you check it still works?
|
||||
- Why are you confident this is ready for merge?
|
||||
|
||||
We're not looking for exhaustive documentation - just evidence that you've thought through the implications of your changes and tested accordingly.
|
||||
-->
|
||||
<!-- How did you test this? Are you confident that it will not introduce bugs? If so, why? -->
|
||||
|
||||
### Type of Change
|
||||
|
||||
@@ -49,10 +13,7 @@ We're not looking for exhaustive documentation - just evidence that you've thoug
|
||||
- [ ] 🐛 Bug fix (non-breaking change which fixes an issue)
|
||||
- [ ] ✨ New feature (non-breaking change which adds functionality)
|
||||
- [ ] 💥 Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||
- [ ] ♻️ Refactor Changes
|
||||
- [ ] 💅 Cosmetic Changes
|
||||
- [ ] 📚 Documentation update
|
||||
- [ ] 🏃 Workflow Changes
|
||||
|
||||
### Pre-flight Checklist
|
||||
|
||||
@@ -65,15 +26,7 @@ We're not looking for exhaustive documentation - just evidence that you've thoug
|
||||
|
||||
### Screenshots
|
||||
|
||||
<!--
|
||||
Help reviewers quickly understand your changes:
|
||||
|
||||
- **UI Changes**: Please include screenshots showing before/after states
|
||||
- **Complex Workflows**: Consider uploading a screen recording (video) if your changes involve multiple steps or state transitions
|
||||
- **Backend Changes**: Not required, but feel free to include terminal output or other evidence that demonstrates functionality
|
||||
|
||||
This helps reviewers see what you've built without having to pull down and test your branch first.
|
||||
-->
|
||||
<!-- For UI changes, add screenshots here -->
|
||||
|
||||
### Additional Notes
|
||||
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
"""
|
||||
Coverage utility package for GitHub Actions workflows.
|
||||
This package handles extracting coverage percentages, comparing them, and generating PR comments.
|
||||
"""
|
||||
|
||||
# Import external dependencies
|
||||
import requests
|
||||
|
||||
# Import main function for CLI usage
|
||||
from .__main__ import main
|
||||
|
||||
# Import functions from extraction module
|
||||
from .extraction import extract_coverage, compare_coverage, run_coverage, set_verbose
|
||||
|
||||
# Import functions from github_api module
|
||||
from .github_api import generate_comment, post_comment, set_github_output
|
||||
|
||||
# Import functions from workflow module
|
||||
from .workflow import process_coverage_workflow
|
||||
@@ -1,154 +0,0 @@
|
||||
"""
|
||||
Main module.
|
||||
This module provides the CLI interface for the coverage utility script.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import argparse
|
||||
|
||||
from .extraction import extract_coverage, compare_coverage, run_coverage, set_verbose
|
||||
from .github_api import generate_comment, post_comment, set_github_output
|
||||
from .workflow import process_coverage_workflow
|
||||
from .util import log
|
||||
|
||||
def setup_verbose_mode(args):
|
||||
"""
|
||||
Set up verbose mode based on command line arguments.
|
||||
|
||||
Args:
|
||||
args: Parsed command line arguments
|
||||
"""
|
||||
if getattr(args, 'verbose', False):
|
||||
set_verbose(True)
|
||||
log("Verbose mode enabled")
|
||||
|
||||
def main():
|
||||
# Create parent parser with common arguments
|
||||
parent_parser = argparse.ArgumentParser(add_help=False)
|
||||
parent_parser.add_argument('-v', '--verbose', action='store_true', help='Enable verbose output')
|
||||
|
||||
# Create main parser that inherits common arguments
|
||||
parser = argparse.ArgumentParser(description='Coverage utility script for GitHub Actions workflows', parents=[parent_parser])
|
||||
subparsers = parser.add_subparsers(dest='command', help='Command to run')
|
||||
|
||||
# extract-coverage command - used directly in workflow
|
||||
extract_parser = subparsers.add_parser('extract-coverage', help='Extract coverage percentage from a file', parents=[parent_parser])
|
||||
extract_parser.add_argument('file_path', help='Path to the coverage report file')
|
||||
extract_parser.add_argument('--type', choices=['extension', 'webview'], default='extension',
|
||||
help='Type of coverage report')
|
||||
extract_parser.add_argument('--github-output', action='store_true', help='Output in GitHub Actions format')
|
||||
|
||||
# compare-coverage command - used by process-workflow
|
||||
compare_parser = subparsers.add_parser('compare-coverage', help='Compare coverage percentages', parents=[parent_parser])
|
||||
compare_parser.add_argument('base_cov', help='Base branch coverage percentage')
|
||||
compare_parser.add_argument('pr_cov', help='PR branch coverage percentage')
|
||||
compare_parser.add_argument('--output-prefix', default='', help='Prefix for GitHub Actions output variables')
|
||||
compare_parser.add_argument('--github-output', action='store_true', help='Output in GitHub Actions format')
|
||||
|
||||
# generate-comment command - used by process-workflow
|
||||
comment_parser = subparsers.add_parser('generate-comment', help='Generate PR comment with coverage comparison', parents=[parent_parser])
|
||||
comment_parser.add_argument('base_ext_cov', help='Base branch extension coverage')
|
||||
comment_parser.add_argument('pr_ext_cov', help='PR branch extension coverage')
|
||||
comment_parser.add_argument('ext_decreased', help='Whether extension coverage decreased (true/false)')
|
||||
comment_parser.add_argument('ext_diff', help='Extension coverage difference')
|
||||
comment_parser.add_argument('base_web_cov', help='Base branch webview coverage')
|
||||
comment_parser.add_argument('pr_web_cov', help='PR branch webview coverage')
|
||||
comment_parser.add_argument('web_decreased', help='Whether webview coverage decreased (true/false)')
|
||||
comment_parser.add_argument('web_diff', help='Webview coverage difference')
|
||||
|
||||
# post-comment command - used by process-workflow
|
||||
post_parser = subparsers.add_parser('post-comment', help='Post a comment to a GitHub PR', parents=[parent_parser])
|
||||
post_parser.add_argument('comment_path', help='Path to the file containing the comment text')
|
||||
post_parser.add_argument('pr_number', help='PR number')
|
||||
post_parser.add_argument('repo', help='Repository in the format "owner/repo"')
|
||||
post_parser.add_argument('--token', help='GitHub token')
|
||||
|
||||
# run-coverage command - used by process-workflow
|
||||
run_parser = subparsers.add_parser('run-coverage', help='Run a coverage command and extract the coverage percentage', parents=[parent_parser])
|
||||
run_parser.add_argument('coverage_cmd', help='Command to run')
|
||||
run_parser.add_argument('output_file', help='File to save the output to')
|
||||
run_parser.add_argument('--type', choices=['extension', 'webview'], default='extension',
|
||||
help='Type of coverage report')
|
||||
run_parser.add_argument('--github-output', action='store_true', help='Output in GitHub Actions format')
|
||||
|
||||
# process-workflow command - used directly in workflow
|
||||
workflow_parser = subparsers.add_parser('process-workflow', help='Process the entire coverage workflow', parents=[parent_parser])
|
||||
workflow_parser.add_argument('--base-branch', required=True, help='Base branch name')
|
||||
workflow_parser.add_argument('--pr-number', help='PR number')
|
||||
workflow_parser.add_argument('--repo', help='Repository in the format "owner/repo"')
|
||||
workflow_parser.add_argument('--token', help='GitHub token')
|
||||
|
||||
# set-github-output command - used by process-workflow
|
||||
output_parser = subparsers.add_parser('set-github-output', help='Set GitHub Actions output variable', parents=[parent_parser])
|
||||
output_parser.add_argument('name', help='Output variable name')
|
||||
output_parser.add_argument('value', help='Output variable value')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Set up verbose mode
|
||||
setup_verbose_mode(args)
|
||||
|
||||
if args.command == 'extract-coverage':
|
||||
log(f"Extracting coverage from file: {args.file_path} (type: {args.type})")
|
||||
coverage_pct = extract_coverage(args.file_path, args.type)
|
||||
if args.github_output:
|
||||
set_github_output(f"{args.type}_coverage", coverage_pct)
|
||||
else:
|
||||
log(f"Coverage: {coverage_pct}%")
|
||||
|
||||
elif args.command == 'compare-coverage':
|
||||
log(f"Comparing coverage: base={args.base_cov}%, PR={args.pr_cov}%")
|
||||
decreased, diff = compare_coverage(args.base_cov, args.pr_cov)
|
||||
if args.github_output:
|
||||
prefix = args.output_prefix
|
||||
set_github_output(f"{prefix}decreased", str(decreased).lower())
|
||||
set_github_output(f"{prefix}diff", diff)
|
||||
log(f"Coverage difference: {diff}%")
|
||||
log(f"Coverage decreased: {decreased}")
|
||||
else:
|
||||
log(f"decreased={str(decreased).lower()}")
|
||||
log(f"diff={diff}")
|
||||
|
||||
elif args.command == 'generate-comment':
|
||||
log("Generating coverage comparison comment")
|
||||
comment = generate_comment(
|
||||
args.base_ext_cov, args.pr_ext_cov, args.ext_decreased, args.ext_diff,
|
||||
args.base_web_cov, args.pr_web_cov, args.web_decreased, args.web_diff
|
||||
)
|
||||
# Output the comment to stdout
|
||||
log(comment)
|
||||
|
||||
elif args.command == 'post-comment':
|
||||
log(f"Posting comment from {args.comment_path} to PR #{args.pr_number} in {args.repo}")
|
||||
post_comment(args.comment_path, args.pr_number, args.repo, args.token)
|
||||
|
||||
elif args.command == 'run-coverage':
|
||||
log(f"Running coverage command: {args.coverage_cmd}")
|
||||
log(f"Output file: {args.output_file}")
|
||||
log(f"Coverage type: {args.type}")
|
||||
coverage_pct = run_coverage(args.coverage_cmd, args.output_file, args.type)
|
||||
if args.github_output:
|
||||
set_github_output(f"{args.type}_coverage", coverage_pct)
|
||||
else:
|
||||
log(f"Coverage: {coverage_pct}%")
|
||||
|
||||
elif args.command == 'process-workflow':
|
||||
log("Processing coverage workflow")
|
||||
log(f"Base branch: {args.base_branch}")
|
||||
if args.pr_number:
|
||||
log(f"PR number: {args.pr_number}")
|
||||
if args.repo:
|
||||
log(f"Repository: {args.repo}")
|
||||
process_coverage_workflow(args)
|
||||
|
||||
elif args.command == 'set-github-output':
|
||||
log(f"Setting GitHub output: {args.name}={args.value}")
|
||||
set_github_output(args.name, args.value)
|
||||
|
||||
else:
|
||||
log("No command specified")
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,265 +0,0 @@
|
||||
"""
|
||||
Coverage extraction module.
|
||||
This module handles extracting coverage percentages from coverage report files.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import shlex
|
||||
import subprocess
|
||||
import traceback
|
||||
from .util import log, file_exists, get_file_size, list_directory, is_safe_command, run_command
|
||||
|
||||
# Global verbose flag
|
||||
verbose = False
|
||||
|
||||
def set_verbose(value):
|
||||
"""Set the global verbose flag."""
|
||||
global verbose
|
||||
verbose = value
|
||||
|
||||
def print_debug_output(content, coverage_type):
|
||||
"""
|
||||
Print debug information about the coverage output.
|
||||
|
||||
Args:
|
||||
content: The content of the coverage file
|
||||
coverage_type: Type of coverage report (extension or webview)
|
||||
"""
|
||||
if not verbose:
|
||||
return
|
||||
|
||||
# Extract and print only the coverage summary section
|
||||
if coverage_type == "extension":
|
||||
# Look for the coverage summary section
|
||||
summary_match = re.search(r'=============================== Coverage summary ===============================\n(.*?)\n=+', content, re.DOTALL)
|
||||
if summary_match:
|
||||
sys.stdout.write("\n##[group]EXTENSION COVERAGE SUMMARY\n")
|
||||
sys.stdout.write("=============================== Coverage summary ===============================\n")
|
||||
sys.stdout.write(summary_match.group(1) + "\n")
|
||||
sys.stdout.write("================================================================================\n")
|
||||
sys.stdout.write("##[endgroup]\n")
|
||||
sys.stdout.flush()
|
||||
else:
|
||||
sys.stdout.write("\n##[warning]No coverage summary found in extension coverage file\n")
|
||||
sys.stdout.flush()
|
||||
else: # webview
|
||||
# Look for the coverage table - specifically the "All files" row
|
||||
table_match = re.search(r'% Coverage report from v8.*?-+\|.*?\n.*?\n(All files.*?)(?:\n[^\n]*\|)', content, re.DOTALL)
|
||||
if table_match:
|
||||
sys.stdout.write("\n##[group]WEBVIEW COVERAGE SUMMARY\n")
|
||||
sys.stdout.write("% Coverage report from v8\n")
|
||||
sys.stdout.write("-------------------|---------|----------|---------|---------|-------------------\n")
|
||||
sys.stdout.write("File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s \n")
|
||||
sys.stdout.write("-------------------|---------|----------|---------|---------|-------------------\n")
|
||||
sys.stdout.write(table_match.group(1) + "\n")
|
||||
sys.stdout.write("-------------------|---------|----------|---------|---------|-------------------\n")
|
||||
sys.stdout.write("##[endgroup]\n")
|
||||
sys.stdout.flush()
|
||||
else:
|
||||
sys.stdout.write("\n##[warning]No coverage table found in webview coverage file\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
def extract_coverage(file_path, coverage_type="extension"):
|
||||
"""
|
||||
Extract coverage percentage from a coverage report file.
|
||||
|
||||
Args:
|
||||
file_path: Path to the coverage report file
|
||||
coverage_type: Type of coverage report (extension or webview)
|
||||
|
||||
Returns:
|
||||
Coverage percentage as a float
|
||||
"""
|
||||
|
||||
# Always print file path for debugging
|
||||
log(f"Checking coverage file: {file_path}")
|
||||
|
||||
# Check if file exists and get its size
|
||||
if not file_exists(file_path):
|
||||
sys.stdout.write(f"\n##[error]File {file_path} does not exist\n")
|
||||
sys.stdout.flush()
|
||||
log(f"Error: File {file_path} does not exist")
|
||||
|
||||
# Check if the directory exists
|
||||
dir_path = os.path.dirname(file_path)
|
||||
if not os.path.exists(dir_path):
|
||||
sys.stdout.write(f"\n##[error]Directory {dir_path} does not exist\n")
|
||||
sys.stdout.flush()
|
||||
log(f"Error: Directory {dir_path} does not exist")
|
||||
else:
|
||||
# List directory contents for debugging
|
||||
log(f"Directory {dir_path} exists, listing contents:")
|
||||
try:
|
||||
dir_contents = list_directory(dir_path)
|
||||
for name, size in dir_contents:
|
||||
log(f" {name} - {size}")
|
||||
sys.stdout.write(f" {name} - {size}\n")
|
||||
sys.stdout.flush()
|
||||
except Exception as e:
|
||||
log(f"Error listing directory: {e}")
|
||||
|
||||
return 0.0
|
||||
|
||||
file_size = get_file_size(file_path)
|
||||
log(f"File size: {file_size} bytes")
|
||||
sys.stdout.write(f"\n##[info]Coverage file {file_path} exists, size: {file_size} bytes\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
if file_size == 0:
|
||||
sys.stdout.write(f"\n##[warning]File {file_path} is empty\n")
|
||||
sys.stdout.flush()
|
||||
log(f"Warning: File {file_path} is empty")
|
||||
return 0.0
|
||||
|
||||
# List directory contents for debugging
|
||||
dir_path = os.path.dirname(file_path)
|
||||
log(f"Directory contents of {dir_path}:")
|
||||
try:
|
||||
dir_contents = list_directory(dir_path)
|
||||
for name, size in dir_contents:
|
||||
log(f" {name} - {size}")
|
||||
except Exception as e:
|
||||
log(f"Error listing directory: {e}")
|
||||
|
||||
with open(file_path, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
# Print debug information if verbose
|
||||
print_debug_output(content, coverage_type)
|
||||
|
||||
# Extract coverage percentage based on coverage type
|
||||
if coverage_type == "extension":
|
||||
# Extract the percentage from the "Lines" row in the coverage summary
|
||||
# Pattern: Lines : xx.xx% ( xxxxxxx/xxxxxxx )
|
||||
lines_match = re.search(r'Lines\s*:\s*(\d+\.\d+)%', content)
|
||||
if lines_match:
|
||||
coverage_pct = float(lines_match.group(1))
|
||||
if verbose:
|
||||
sys.stdout.write(f"Pattern matched (Lines percentage): {coverage_pct}\n")
|
||||
sys.stdout.flush()
|
||||
return coverage_pct
|
||||
else:
|
||||
# No coverage data found, log full content for debugging
|
||||
log("No coverage data found. Full file content:")
|
||||
log("=== Full file content ===")
|
||||
log(content)
|
||||
log("=== End file content ===")
|
||||
else: # webview
|
||||
# Extract the percentage from the "% Lines" column in the "All files" row
|
||||
# Pattern: All files | xx.xx | xx.xx | xx.xx | xx.xx |
|
||||
all_files_match = re.search(r'All files\s+\|\s+\d+\.\d+\s+\|\s+\d+\.\d+\s+\|\s+\d+\.\d+\s+\|\s+(\d+\.\d+)', content)
|
||||
if all_files_match:
|
||||
coverage_pct = float(all_files_match.group(1))
|
||||
if verbose:
|
||||
sys.stdout.write(f"Pattern matched (All files % Lines): {coverage_pct}\n")
|
||||
sys.stdout.flush()
|
||||
return coverage_pct
|
||||
else:
|
||||
# No coverage data found, log full content for debugging
|
||||
log("No coverage data found. Full file content:")
|
||||
log("=== Full file content ===")
|
||||
log(content)
|
||||
log("=== End file content ===")
|
||||
|
||||
# If no match found, return 0.0
|
||||
return 0.0
|
||||
|
||||
def compare_coverage(base_cov, pr_cov):
|
||||
"""
|
||||
Compare coverage percentages between base and PR branches.
|
||||
|
||||
Args:
|
||||
base_cov: Base branch coverage percentage
|
||||
pr_cov: PR branch coverage percentage
|
||||
|
||||
Returns:
|
||||
Tuple of (decreased, diff)
|
||||
"""
|
||||
try:
|
||||
base_cov = float(base_cov)
|
||||
pr_cov = float(pr_cov)
|
||||
except ValueError:
|
||||
sys.stdout.write(f"Error: Invalid coverage values - base: {base_cov}, PR: {pr_cov}\n")
|
||||
sys.stdout.flush()
|
||||
return False, 0
|
||||
|
||||
diff = pr_cov - base_cov
|
||||
decreased = diff < 0
|
||||
|
||||
return decreased, abs(diff)
|
||||
|
||||
def run_coverage(command, output_file, coverage_type="extension"):
|
||||
"""
|
||||
Run a coverage command and extract the coverage percentage.
|
||||
|
||||
Args:
|
||||
command: Command to run
|
||||
output_file: File to save the output to
|
||||
coverage_type: Type of coverage report (extension or webview)
|
||||
|
||||
Returns:
|
||||
Coverage percentage as a float
|
||||
|
||||
Raises:
|
||||
SystemExit: If the output file is not created or is empty
|
||||
"""
|
||||
|
||||
try:
|
||||
# Run the command and capture output
|
||||
if not is_safe_command(command):
|
||||
error_msg = f"ERROR: Unsafe command detected: {command}"
|
||||
log(error_msg)
|
||||
sys.stdout.write(f"\n##[error]{error_msg}\n")
|
||||
sys.stdout.flush()
|
||||
sys.exit(1)
|
||||
|
||||
# Run command using safe execution from util
|
||||
returncode, stdout, stderr = run_command(command)
|
||||
|
||||
# Log command result
|
||||
log(f"Command exit code: {returncode}")
|
||||
log(f"Command stdout length: {len(stdout)} bytes")
|
||||
log(f"Command stderr length: {len(stderr)} bytes")
|
||||
|
||||
# Save output to file
|
||||
log(f"Saving command output to {output_file}")
|
||||
with open(output_file, 'w') as f:
|
||||
f.write(stdout)
|
||||
if stderr:
|
||||
f.write("\n\n=== STDERR ===\n")
|
||||
f.write(stderr)
|
||||
|
||||
# Verify file was created and has content
|
||||
if not file_exists(output_file):
|
||||
error_msg = f"ERROR: Output file {output_file} was not created"
|
||||
log(error_msg)
|
||||
sys.stdout.write(f"\n##[error]{error_msg}\n")
|
||||
sys.stdout.flush()
|
||||
sys.exit(1) # Exit with error code to fail the workflow
|
||||
|
||||
file_size = get_file_size(output_file)
|
||||
if file_size == 0:
|
||||
error_msg = f"ERROR: Output file {output_file} is empty"
|
||||
log(error_msg)
|
||||
sys.stdout.write(f"\n##[error]{error_msg}\n")
|
||||
sys.stdout.flush()
|
||||
sys.exit(1) # Exit with error code to fail the workflow
|
||||
|
||||
log(f"Output file size: {file_size} bytes")
|
||||
|
||||
# Extract coverage percentage
|
||||
coverage_pct = extract_coverage(output_file, coverage_type)
|
||||
|
||||
log(f"{coverage_type.capitalize()} coverage: {coverage_pct}%")
|
||||
return coverage_pct
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error running coverage command: {e}"
|
||||
log(error_msg)
|
||||
sys.stdout.write(f"\n##[error]{error_msg}\n")
|
||||
sys.stdout.flush()
|
||||
# Print stack trace for debugging
|
||||
log(traceback.format_exc())
|
||||
sys.exit(1) # Exit with error code to fail the workflow
|
||||
@@ -1,177 +0,0 @@
|
||||
"""
|
||||
GitHub API module.
|
||||
This module handles interactions with the GitHub API for posting comments to PRs.
|
||||
"""
|
||||
|
||||
import os
|
||||
import requests
|
||||
from .util import log, file_exists
|
||||
|
||||
def generate_comment(base_ext_cov, pr_ext_cov, ext_decreased, ext_diff,
|
||||
base_web_cov, pr_web_cov, web_decreased, web_diff):
|
||||
"""
|
||||
Generate a PR comment with coverage comparison.
|
||||
|
||||
Args:
|
||||
base_ext_cov: Base branch extension coverage
|
||||
pr_ext_cov: PR branch extension coverage
|
||||
ext_decreased: Whether extension coverage decreased
|
||||
ext_diff: Extension coverage difference
|
||||
base_web_cov: Base branch webview coverage
|
||||
pr_web_cov: PR branch webview coverage
|
||||
web_decreased: Whether webview coverage decreased
|
||||
web_diff: Webview coverage difference
|
||||
|
||||
Returns:
|
||||
Comment text
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
# Convert string inputs to appropriate types
|
||||
try:
|
||||
base_ext_cov = float(base_ext_cov)
|
||||
pr_ext_cov = float(pr_ext_cov)
|
||||
# Handle ext_decreased as either string or boolean
|
||||
if isinstance(ext_decreased, str):
|
||||
ext_decreased = ext_decreased.lower() == 'true'
|
||||
else:
|
||||
ext_decreased = bool(ext_decreased)
|
||||
ext_diff = float(ext_diff)
|
||||
base_web_cov = float(base_web_cov)
|
||||
pr_web_cov = float(pr_web_cov)
|
||||
# Handle web_decreased as either string or boolean
|
||||
if isinstance(web_decreased, str):
|
||||
web_decreased = web_decreased.lower() == 'true'
|
||||
else:
|
||||
web_decreased = bool(web_decreased)
|
||||
web_diff = float(web_diff)
|
||||
except ValueError as e:
|
||||
log(f"Error converting input values: {e}")
|
||||
return ""
|
||||
|
||||
# Add a unique identifier to find this comment later
|
||||
comment = '<!-- COVERAGE_REPORT -->\n'
|
||||
comment += '## Coverage Report\n\n'
|
||||
|
||||
# Extension coverage
|
||||
comment += '### Extension Coverage\n\n'
|
||||
comment += f'Base branch: {base_ext_cov:.0f}%\n\n'
|
||||
comment += f'PR branch: {pr_ext_cov:.0f}%\n\n'
|
||||
|
||||
if ext_decreased:
|
||||
comment += f'⚠️ **Warning: Coverage decreased by {ext_diff:.2f}%**\n\n'
|
||||
comment += 'Consider adding tests to cover your changes.\n\n'
|
||||
else:
|
||||
comment += '✅ Coverage increased or remained the same\n\n'
|
||||
|
||||
# Webview coverage
|
||||
comment += '### Webview Coverage\n\n'
|
||||
comment += f'Base branch: {base_web_cov:.0f}%\n\n'
|
||||
comment += f'PR branch: {pr_web_cov:.0f}%\n\n'
|
||||
|
||||
if web_decreased:
|
||||
comment += f'⚠️ **Warning: Coverage decreased by {web_diff:.2f}%**\n\n'
|
||||
comment += 'Consider adding tests to cover your changes.\n\n'
|
||||
else:
|
||||
comment += '✅ Coverage increased or remained the same\n\n'
|
||||
|
||||
# Overall assessment
|
||||
comment += '### Overall Assessment\n\n'
|
||||
if ext_decreased or web_decreased:
|
||||
comment += '⚠️ **Test coverage has decreased in this PR**\n\n'
|
||||
comment += 'Please consider adding tests to maintain or improve coverage.\n\n'
|
||||
else:
|
||||
comment += '✅ **Test coverage has been maintained or improved**\n\n'
|
||||
|
||||
# Add timestamp
|
||||
comment += f'\n\n<sub>Last updated: {datetime.now().isoformat()}</sub>'
|
||||
|
||||
return comment
|
||||
|
||||
def post_comment(comment_path, pr_number, repo, token=None):
|
||||
"""
|
||||
Post a comment to a GitHub PR.
|
||||
|
||||
Args:
|
||||
comment_path: Path to the file containing the comment text
|
||||
pr_number: PR number
|
||||
repo: Repository in the format "owner/repo"
|
||||
token: GitHub token
|
||||
"""
|
||||
if not file_exists(comment_path):
|
||||
log(f"Error: Comment file {comment_path} does not exist")
|
||||
return
|
||||
|
||||
with open(comment_path, 'r') as f:
|
||||
comment_body = f.read()
|
||||
|
||||
if not token:
|
||||
token = os.environ.get('GITHUB_TOKEN')
|
||||
if not token:
|
||||
log("Error: GitHub token not provided")
|
||||
return
|
||||
|
||||
# Find existing comment
|
||||
headers = {
|
||||
'Authorization': f'token {token}',
|
||||
'Accept': 'application/vnd.github.v3+json'
|
||||
}
|
||||
|
||||
# Get all comments
|
||||
comments_url = f'https://api.github.com/repos/{repo}/issues/{pr_number}/comments'
|
||||
log(f"Getting comments from: {comments_url}")
|
||||
response = requests.get(comments_url, headers=headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
log(f"Error getting comments: {response.status_code} - {response.text}")
|
||||
return
|
||||
|
||||
comments = response.json()
|
||||
log(f"Found {len(comments)} existing comments")
|
||||
|
||||
# Find comment with our identifier
|
||||
comment_id = None
|
||||
for comment in comments:
|
||||
if '<!-- COVERAGE_REPORT -->' in comment['body']:
|
||||
comment_id = comment['id']
|
||||
log(f"Found existing coverage report comment with ID: {comment_id}")
|
||||
break
|
||||
|
||||
if comment_id:
|
||||
# Update existing comment
|
||||
update_url = f'https://api.github.com/repos/{repo}/issues/comments/{comment_id}'
|
||||
log(f"Updating existing comment at: {update_url}")
|
||||
response = requests.patch(update_url, headers=headers, json={'body': comment_body})
|
||||
|
||||
if response.status_code == 200:
|
||||
log(f"Successfully updated existing comment: {comment_id}")
|
||||
else:
|
||||
log(f"Error updating comment: {response.status_code} - {response.text}")
|
||||
else:
|
||||
# Create new comment
|
||||
log(f"Creating new comment at: {comments_url}")
|
||||
response = requests.post(comments_url, headers=headers, json={'body': comment_body})
|
||||
|
||||
if response.status_code == 201:
|
||||
log("Successfully created new comment")
|
||||
else:
|
||||
log(f"Error creating comment: {response.status_code} - {response.text}")
|
||||
|
||||
def set_github_output(name, value):
|
||||
"""
|
||||
Set GitHub Actions output variable.
|
||||
|
||||
Args:
|
||||
name: Output variable name
|
||||
value: Output variable value
|
||||
"""
|
||||
# Write to the GitHub output file if available
|
||||
if 'GITHUB_OUTPUT' in os.environ:
|
||||
with open(os.environ['GITHUB_OUTPUT'], 'a') as f:
|
||||
f.write(f"{name}={value}\n")
|
||||
else:
|
||||
# Fallback to the deprecated method for backward compatibility
|
||||
log(f"::set-output name={name}::{value}")
|
||||
|
||||
# Also print for human readability
|
||||
log(f"{name}: {value}")
|
||||
@@ -1,245 +0,0 @@
|
||||
"""
|
||||
Utility module.
|
||||
This module provides utility functions used across the coverage check scripts.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import traceback
|
||||
from typing import List, Tuple, Dict, Any, Optional, Union
|
||||
|
||||
# List of allowed commands and their arguments
|
||||
ALLOWED_COMMANDS = {
|
||||
'xvfb-run': ['-a'],
|
||||
'npm': ['run', 'test:coverage', 'ci', 'install', '--no-save', '@vitest/coverage-v8', 'check-types', 'lint', 'format', 'compile'],
|
||||
'cd': ['webview-ui'],
|
||||
'python': ['-m', 'coverage_check'],
|
||||
'git': ['fetch', 'checkout', 'origin'],
|
||||
}
|
||||
|
||||
def is_safe_command(command: Union[str, List[str]]) -> bool:
|
||||
"""
|
||||
Check if a command is safe to execute.
|
||||
|
||||
Args:
|
||||
command: Command to check (string or list)
|
||||
|
||||
Returns:
|
||||
True if command is safe, False otherwise
|
||||
"""
|
||||
# Convert string command to list
|
||||
if isinstance(command, str):
|
||||
try:
|
||||
cmd_parts = shlex.split(command)
|
||||
except ValueError:
|
||||
return False
|
||||
else:
|
||||
cmd_parts = command
|
||||
|
||||
if not cmd_parts:
|
||||
return False
|
||||
|
||||
# Get base command
|
||||
base_cmd = os.path.basename(cmd_parts[0])
|
||||
|
||||
# Check if command is in allowed list
|
||||
if base_cmd not in ALLOWED_COMMANDS:
|
||||
return False
|
||||
|
||||
# For each argument, check for suspicious patterns
|
||||
for arg in cmd_parts[1:]:
|
||||
# Check for shell metacharacters
|
||||
if re.search(r'[;&|`$]', arg):
|
||||
return False
|
||||
# Check for path traversal
|
||||
if '..' in arg and not (base_cmd == 'npm' and arg.startswith('@')):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def log(message: str) -> None:
|
||||
"""
|
||||
Write a message to stdout and flush.
|
||||
|
||||
Args:
|
||||
message: The message to write
|
||||
"""
|
||||
sys.stdout.write(f"{message}\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
def file_exists(file_path: str) -> bool:
|
||||
"""
|
||||
Check if a file exists.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file
|
||||
|
||||
Returns:
|
||||
True if the file exists, False otherwise
|
||||
"""
|
||||
return os.path.exists(file_path) and os.path.isfile(file_path)
|
||||
|
||||
def get_file_size(file_path: str) -> int:
|
||||
"""
|
||||
Get the size of a file in bytes.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file
|
||||
|
||||
Returns:
|
||||
Size of the file in bytes, or 0 if the file doesn't exist
|
||||
"""
|
||||
if file_exists(file_path):
|
||||
return os.path.getsize(file_path)
|
||||
return 0
|
||||
|
||||
def list_directory(dir_path: str) -> List[Tuple[str, Union[int, str]]]:
|
||||
"""
|
||||
List the contents of a directory.
|
||||
|
||||
Args:
|
||||
dir_path: Path to the directory
|
||||
|
||||
Returns:
|
||||
List of (name, size) tuples for each file/directory in the directory
|
||||
"""
|
||||
if not os.path.exists(dir_path) or not os.path.isdir(dir_path):
|
||||
return []
|
||||
|
||||
contents = []
|
||||
for item in os.listdir(dir_path):
|
||||
item_path = os.path.join(dir_path, item)
|
||||
if os.path.isfile(item_path):
|
||||
contents.append((item, os.path.getsize(item_path)))
|
||||
else:
|
||||
contents.append((item, "DIR"))
|
||||
|
||||
return contents
|
||||
|
||||
def read_file_content(file_path: str, default: str = "") -> str:
|
||||
"""
|
||||
Read file content with error handling.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file
|
||||
default: Default value to return if file cannot be read
|
||||
|
||||
Returns:
|
||||
File content or default value
|
||||
"""
|
||||
if not file_exists(file_path):
|
||||
log(f"File does not exist: {file_path}")
|
||||
return default
|
||||
|
||||
try:
|
||||
with open(file_path, 'r') as f:
|
||||
return f.read()
|
||||
except Exception as e:
|
||||
log(f"Error reading file {file_path}: {e}")
|
||||
return default
|
||||
|
||||
def write_file_content(file_path: str, content: str) -> bool:
|
||||
"""
|
||||
Write content to file with error handling.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file
|
||||
content: Content to write
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Create directory if it doesn't exist
|
||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||
|
||||
with open(file_path, 'w') as f:
|
||||
f.write(content)
|
||||
return True
|
||||
except Exception as e:
|
||||
log(f"Error writing to file {file_path}: {e}")
|
||||
return False
|
||||
|
||||
def run_command(command: Union[str, List[str]], capture_output: bool = True) -> Tuple[int, str, str]:
|
||||
"""
|
||||
Run a command and return the result.
|
||||
|
||||
Args:
|
||||
command: Command to run (string or list)
|
||||
capture_output: Whether to capture stdout/stderr
|
||||
|
||||
Returns:
|
||||
Tuple of (returncode, stdout, stderr)
|
||||
"""
|
||||
if not is_safe_command(command):
|
||||
error_msg = f"Unsafe command detected: {command}"
|
||||
log(error_msg)
|
||||
return 1, "", error_msg
|
||||
|
||||
log(f"Running command: {command}")
|
||||
try:
|
||||
# Convert string command to list
|
||||
if isinstance(command, str):
|
||||
cmd_list = shlex.split(command)
|
||||
else:
|
||||
cmd_list = command
|
||||
|
||||
result = subprocess.run(
|
||||
cmd_list,
|
||||
shell=False, # Never use shell=True for security
|
||||
capture_output=capture_output,
|
||||
text=True
|
||||
)
|
||||
log(f"Command exit code: {result.returncode}")
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
except Exception as e:
|
||||
log(f"Error running command: {e}")
|
||||
log(traceback.format_exc())
|
||||
return 1, "", str(e)
|
||||
|
||||
def find_pattern(content: str, pattern: str, group: int = 0,
|
||||
default: Optional[str] = None) -> Optional[str]:
|
||||
"""
|
||||
Find a pattern in content and return the specified group.
|
||||
|
||||
Args:
|
||||
content: Text content to search
|
||||
pattern: Regex pattern to search for
|
||||
group: Group number to return (default: 0 for entire match)
|
||||
default: Default value to return if pattern not found
|
||||
|
||||
Returns:
|
||||
Matched text or default value
|
||||
"""
|
||||
match = re.search(pattern, content, re.DOTALL)
|
||||
if match:
|
||||
return match.group(group)
|
||||
return default
|
||||
|
||||
def get_env_var(name: str, default: Optional[str] = None) -> Optional[str]:
|
||||
"""
|
||||
Get environment variable with default value.
|
||||
|
||||
Args:
|
||||
name: Environment variable name
|
||||
default: Default value if not set
|
||||
|
||||
Returns:
|
||||
Environment variable value or default
|
||||
"""
|
||||
return os.environ.get(name, default)
|
||||
|
||||
def format_exception(e: Exception) -> str:
|
||||
"""
|
||||
Format an exception with traceback for logging.
|
||||
|
||||
Args:
|
||||
e: Exception to format
|
||||
|
||||
Returns:
|
||||
Formatted exception string
|
||||
"""
|
||||
return f"{type(e).__name__}: {str(e)}\n{traceback.format_exc()}"
|
||||
@@ -1,432 +0,0 @@
|
||||
"""
|
||||
Workflow module.
|
||||
This module handles the main workflow logic for running coverage tests and processing results.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import subprocess
|
||||
import traceback
|
||||
|
||||
from .extraction import run_coverage, compare_coverage, extract_coverage
|
||||
from .github_api import generate_comment, post_comment, set_github_output
|
||||
from .util import log, file_exists, get_file_size, list_directory, run_command
|
||||
|
||||
def is_valid_branch_name(branch_name: str) -> bool:
|
||||
"""
|
||||
Validate a git branch name.
|
||||
|
||||
Args:
|
||||
branch_name: Branch name to validate
|
||||
|
||||
Returns:
|
||||
True if valid, False otherwise
|
||||
"""
|
||||
# Check for common branch name patterns
|
||||
if not re.match(r'^[a-zA-Z0-9_\-./]+$', branch_name):
|
||||
return False
|
||||
|
||||
# Check for path traversal
|
||||
if '..' in branch_name:
|
||||
return False
|
||||
|
||||
# Check for shell metacharacters
|
||||
if re.search(r'[;&|`$]', branch_name):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def checkout_branch(branch_name: str) -> None:
|
||||
"""
|
||||
Checkout a branch for testing.
|
||||
|
||||
Args:
|
||||
branch_name: Branch name to checkout
|
||||
|
||||
Raises:
|
||||
RuntimeError: If branch checkout fails
|
||||
ValueError: If branch name is invalid
|
||||
"""
|
||||
if not is_valid_branch_name(branch_name):
|
||||
raise ValueError(f"Invalid branch name: {branch_name}")
|
||||
|
||||
log(f"=== Checking out branch: {branch_name} ===")
|
||||
|
||||
# Fetch the branch
|
||||
returncode, stdout, stderr = run_command(['git', 'fetch', 'origin', branch_name])
|
||||
if returncode != 0:
|
||||
log(f"ERROR: Failed to fetch branch {branch_name}")
|
||||
log(f"Error details: {stderr}")
|
||||
raise RuntimeError(f"Git fetch failed: {stderr}")
|
||||
|
||||
# Checkout the branch
|
||||
returncode, stdout, stderr = run_command(['git', 'checkout', branch_name])
|
||||
if returncode != 0:
|
||||
log(f"ERROR: Failed to checkout branch {branch_name}")
|
||||
log(f"Error details: {stderr}")
|
||||
raise RuntimeError(f"Git checkout failed: {stderr}")
|
||||
|
||||
log(f"Successfully checked out branch: {branch_name}")
|
||||
|
||||
def extract_extension_coverage_from_file(file_path):
|
||||
"""Extract extension coverage from file when run_coverage returns 0."""
|
||||
if not file_exists(file_path):
|
||||
log(f"File {file_path} does not exist, cannot extract extension coverage")
|
||||
return 0.0
|
||||
|
||||
file_size = get_file_size(file_path)
|
||||
if file_size == 0:
|
||||
log(f"File {file_path} is empty, cannot extract extension coverage")
|
||||
return 0.0
|
||||
|
||||
log(f"Extension coverage is 0.0, trying to read from file directly: {file_path} (size: {file_size} bytes)")
|
||||
with open(file_path, 'r') as f:
|
||||
content = f.read()
|
||||
# Extract the percentage from the "Lines" row in the coverage summary
|
||||
# Pattern: Lines : xx.xx% ( xxxxxxx/xxxxxxx )
|
||||
lines_match = re.search(r'Lines\s*:\s*(\d+\.\d+)%', content)
|
||||
if lines_match:
|
||||
coverage = float(lines_match.group(1))
|
||||
log(f"Found extension coverage in file: {coverage}%")
|
||||
return coverage
|
||||
return 0.0
|
||||
|
||||
def extract_webview_coverage_from_file(file_path):
|
||||
"""Extract webview coverage from file when run_coverage returns 0."""
|
||||
if not file_exists(file_path):
|
||||
log(f"File {file_path} does not exist, cannot extract webview coverage")
|
||||
return 0.0
|
||||
|
||||
file_size = get_file_size(file_path)
|
||||
if file_size == 0:
|
||||
log(f"File {file_path} is empty, cannot extract webview coverage")
|
||||
return 0.0
|
||||
|
||||
log(f"Webview coverage is 0.0, trying to read from file directly: {file_path} (size: {file_size} bytes)")
|
||||
with open(file_path, 'r') as f:
|
||||
content = f.read()
|
||||
# Extract the percentage from the "% Lines" column in the "All files" row
|
||||
# Pattern: All files | xx.xx | xx.xx | xx.xx | xx.xx |
|
||||
all_files_match = re.search(r'All files\s+\|\s+\d+\.\d+\s+\|\s+\d+\.\d+\s+\|\s+\d+\.\d+\s+\|\s+(\d+\.\d+)', content)
|
||||
if all_files_match:
|
||||
coverage = float(all_files_match.group(1))
|
||||
log(f"Found webview coverage in file: {coverage}%")
|
||||
return coverage
|
||||
return 0.0
|
||||
|
||||
def run_extension_coverage(branch_name=None):
|
||||
"""Run extension coverage tests and extract results."""
|
||||
prefix = 'base_' if branch_name else ''
|
||||
file_path = f"{prefix}extension_coverage.txt"
|
||||
|
||||
# Run coverage tests
|
||||
ext_cov = run_coverage(
|
||||
["xvfb-run", "-a", "npm", "run", "test:coverage"],
|
||||
file_path,
|
||||
"extension"
|
||||
)
|
||||
|
||||
# If coverage is 0.0, try to extract from file directly
|
||||
if ext_cov == 0.0:
|
||||
ext_cov = extract_extension_coverage_from_file(file_path)
|
||||
|
||||
return ext_cov
|
||||
|
||||
def run_webview_coverage(branch_name=None):
|
||||
"""Run webview coverage tests and extract results."""
|
||||
prefix = 'base_' if branch_name else ''
|
||||
file_path = f"{prefix}webview_coverage.txt"
|
||||
|
||||
# Save current directory
|
||||
original_dir = os.getcwd()
|
||||
|
||||
try:
|
||||
# Change to webview-ui directory
|
||||
os.chdir('webview-ui')
|
||||
|
||||
# Install coverage dependency
|
||||
returncode, stdout, stderr = run_command(["npm", "install", "--no-save", "@vitest/coverage-v8"])
|
||||
if returncode != 0:
|
||||
log(f"Failed to install coverage dependency: {stderr}")
|
||||
return 0.0
|
||||
|
||||
# Run coverage tests from webview-ui directory
|
||||
web_cov = run_coverage(
|
||||
["npm", "run", "test:coverage"],
|
||||
os.path.join('..', file_path),
|
||||
"webview"
|
||||
)
|
||||
finally:
|
||||
# Always change back to original directory
|
||||
os.chdir(original_dir)
|
||||
|
||||
# If coverage is 0.0, try to extract from file directly
|
||||
if web_cov == 0.0:
|
||||
web_cov = extract_webview_coverage_from_file(file_path)
|
||||
|
||||
return web_cov
|
||||
|
||||
def run_branch_coverage(branch_name=None):
|
||||
"""
|
||||
Run coverage tests for a branch.
|
||||
|
||||
Args:
|
||||
branch_name: Name of the branch to checkout before running tests (optional)
|
||||
|
||||
Returns:
|
||||
Tuple of (extension_coverage, webview_coverage)
|
||||
"""
|
||||
# Checkout branch if specified
|
||||
if branch_name:
|
||||
checkout_branch(branch_name)
|
||||
|
||||
# Run coverage tests
|
||||
log(f"=== Running coverage tests{' for ' + branch_name if branch_name else ''} ===")
|
||||
|
||||
# Run extension and webview coverage
|
||||
ext_cov = run_extension_coverage(branch_name)
|
||||
web_cov = run_webview_coverage(branch_name)
|
||||
|
||||
return ext_cov, web_cov
|
||||
|
||||
def find_potential_coverage_files():
|
||||
"""Find potential coverage files in the current directory and webview-ui."""
|
||||
log("Searching for potential coverage files...")
|
||||
|
||||
# Find files in current directory
|
||||
current_dir_files = list_directory('.')
|
||||
for name, size in current_dir_files:
|
||||
if 'coverage' in name.lower() and size != "DIR":
|
||||
log(f"Found potential coverage file: {name} (size: {size} bytes)")
|
||||
|
||||
# Find files in webview-ui directory
|
||||
if os.path.exists('webview-ui') and os.path.isdir('webview-ui'):
|
||||
webview_files = list_directory('webview-ui')
|
||||
for name, size in webview_files:
|
||||
if 'coverage' in name.lower() and size != "DIR":
|
||||
log(f"Found potential webview coverage file: webview-ui/{name} (size: {size} bytes)")
|
||||
else:
|
||||
log("webview-ui directory not found")
|
||||
|
||||
def generate_warnings(base_ext_cov, pr_ext_cov, ext_decreased, ext_diff,
|
||||
base_web_cov, pr_web_cov, web_decreased, web_diff):
|
||||
"""Generate warnings for coverage decreases."""
|
||||
if not (ext_decreased or web_decreased):
|
||||
return []
|
||||
|
||||
warnings = [
|
||||
"Test coverage has decreased in this PR",
|
||||
f"Extension coverage: {base_ext_cov}% -> {pr_ext_cov}% (Diff: {ext_diff}%)",
|
||||
f"Webview coverage: {base_web_cov}% -> {pr_web_cov}% (Diff: {web_diff}%)"
|
||||
]
|
||||
|
||||
# Additional warning for significant decrease (more than 1%)
|
||||
if ext_decreased and ext_diff > 1.0:
|
||||
warnings.append(f"Extension coverage decreased by more than 1% ({ext_diff}%). Consider adding tests to cover your changes.")
|
||||
|
||||
if web_decreased and web_diff > 1.0:
|
||||
warnings.append(f"Webview coverage decreased by more than 1% ({web_diff}%). Consider adding tests to cover your changes.")
|
||||
|
||||
return warnings
|
||||
|
||||
def output_warnings(warnings):
|
||||
"""Output warnings to GitHub step summary and console."""
|
||||
if not warnings:
|
||||
return
|
||||
|
||||
# Get the GitHub step summary file path from environment variable
|
||||
github_step_summary = os.environ.get('GITHUB_STEP_SUMMARY')
|
||||
|
||||
# Write to GitHub step summary if available
|
||||
if github_step_summary:
|
||||
with open(github_step_summary, 'a') as f:
|
||||
f.write("## Coverage Warnings\n\n")
|
||||
for warning in warnings:
|
||||
f.write(f"⚠️ {warning}\n\n")
|
||||
|
||||
# Also output to console with ::warning:: syntax for backward compatibility
|
||||
for warning in warnings:
|
||||
log(f"::warning::{warning}")
|
||||
|
||||
def output_github_results(pr_ext_cov, pr_web_cov, base_ext_cov, base_web_cov,
|
||||
ext_decreased, ext_diff, web_decreased, web_diff):
|
||||
"""Output results for GitHub Actions."""
|
||||
set_github_output("pr_extension_coverage", pr_ext_cov)
|
||||
set_github_output("pr_webview_coverage", pr_web_cov)
|
||||
set_github_output("base_extension_coverage", base_ext_cov)
|
||||
set_github_output("base_webview_coverage", base_web_cov)
|
||||
set_github_output("extension_decreased", str(ext_decreased).lower())
|
||||
set_github_output("extension_diff", ext_diff)
|
||||
set_github_output("webview_decreased", str(web_decreased).lower())
|
||||
set_github_output("webview_diff", web_diff)
|
||||
|
||||
def extract_pr_coverage_from_artifacts():
|
||||
"""
|
||||
Extract PR branch coverage from artifact files.
|
||||
|
||||
Returns:
|
||||
Tuple of (extension_coverage, webview_coverage)
|
||||
|
||||
Raises:
|
||||
SystemExit: If the coverage files don't exist
|
||||
"""
|
||||
log("=== Extracting PR branch coverage from artifacts ===")
|
||||
|
||||
# Check if the coverage files exist
|
||||
ext_file_path = "extension_coverage.txt"
|
||||
web_file_path = "webview-ui/webview_coverage.txt"
|
||||
|
||||
# Extract extension coverage
|
||||
log(f"Extracting extension coverage from {ext_file_path}")
|
||||
if not file_exists(ext_file_path):
|
||||
error_msg = f"ERROR: PR extension coverage file {ext_file_path} not found"
|
||||
log(error_msg)
|
||||
|
||||
# List directory contents for debugging
|
||||
log("Current directory contents:")
|
||||
try:
|
||||
dir_contents = list_directory('.')
|
||||
for name, size in dir_contents:
|
||||
log(f" {name} - {size}\n")
|
||||
except Exception as e:
|
||||
log(f"Error listing directory: {e}")
|
||||
|
||||
sys.exit(1) # Exit with error code to fail the workflow
|
||||
|
||||
ext_cov = extract_extension_coverage_from_file(ext_file_path)
|
||||
log(f"PR extension coverage from artifact: {ext_cov}%")
|
||||
|
||||
# Extract webview coverage
|
||||
log(f"Extracting webview coverage from {web_file_path}")
|
||||
if not file_exists(web_file_path):
|
||||
error_msg = f"ERROR: PR webview coverage file {web_file_path} not found"
|
||||
log(error_msg)
|
||||
|
||||
# Check if the webview-ui directory exists
|
||||
if not os.path.exists('webview-ui'):
|
||||
log("ERROR: webview-ui directory not found")
|
||||
else:
|
||||
# List webview-ui directory contents for debugging
|
||||
log("webview-ui directory contents:")
|
||||
try:
|
||||
dir_contents = list_directory('webview-ui')
|
||||
for name, size in dir_contents:
|
||||
log(f" {name} - {size}")
|
||||
except Exception as e:
|
||||
log(f"Error listing directory: {e}")
|
||||
|
||||
sys.exit(1) # Exit with error code to fail the workflow
|
||||
|
||||
web_cov = extract_webview_coverage_from_file(web_file_path)
|
||||
log(f"PR webview coverage from artifact: {web_cov}%")
|
||||
|
||||
return ext_cov, web_cov
|
||||
|
||||
def process_coverage_workflow(args):
|
||||
"""
|
||||
Process the entire coverage workflow.
|
||||
|
||||
Args:
|
||||
args: Command line arguments
|
||||
"""
|
||||
# Initialize all variables at the start
|
||||
pr_ext_cov = 0.0
|
||||
pr_web_cov = 0.0
|
||||
base_ext_cov = 0.0
|
||||
base_web_cov = 0.0
|
||||
ext_decreased = False
|
||||
ext_diff = 0.0
|
||||
web_decreased = False
|
||||
web_diff = 0.0
|
||||
|
||||
try:
|
||||
# Validate branch name
|
||||
if not is_valid_branch_name(args.base_branch):
|
||||
raise ValueError(f"Invalid base branch name: {args.base_branch}")
|
||||
|
||||
# Check if we're running in GitHub Actions
|
||||
is_github_actions = 'GITHUB_ACTIONS' in os.environ
|
||||
if is_github_actions:
|
||||
log("Running in GitHub Actions environment")
|
||||
|
||||
# Extract PR branch coverage from artifacts (from test job)
|
||||
pr_ext_cov, pr_web_cov = extract_pr_coverage_from_artifacts()
|
||||
|
||||
# Verify PR coverage values
|
||||
if pr_ext_cov == 0.0:
|
||||
log("WARNING: PR extension coverage is 0.0, this may indicate an issue with the coverage report")
|
||||
find_potential_coverage_files()
|
||||
|
||||
if pr_web_cov == 0.0:
|
||||
log("WARNING: PR webview coverage is 0.0, this may indicate an issue with the coverage report")
|
||||
find_potential_coverage_files()
|
||||
|
||||
# Run base branch coverage
|
||||
log(f"=== Running base branch coverage for {args.base_branch} ===")
|
||||
base_ext_cov, base_web_cov = run_branch_coverage(args.base_branch)
|
||||
|
||||
# Verify base coverage values
|
||||
if base_ext_cov == 0.0:
|
||||
log("WARNING: Base extension coverage is 0.0, this may indicate an issue with the coverage report")
|
||||
|
||||
if base_web_cov == 0.0:
|
||||
log("WARNING: Base webview coverage is 0.0, this may indicate an issue with the coverage report")
|
||||
|
||||
# Compare coverage
|
||||
log("=== Comparing extension coverage ===")
|
||||
ext_decreased, ext_diff = compare_coverage(base_ext_cov, pr_ext_cov)
|
||||
|
||||
log("=== Comparing webview coverage ===")
|
||||
web_decreased, web_diff = compare_coverage(base_web_cov, pr_web_cov)
|
||||
|
||||
# Print summary of coverage values
|
||||
log("\n=== Coverage Summary ===")
|
||||
log(f"PR extension coverage: {pr_ext_cov}%")
|
||||
log(f"Base extension coverage: {base_ext_cov}%")
|
||||
log(f"Extension coverage change: {'+' if not ext_decreased else '-'}{ext_diff}%")
|
||||
log(f"PR webview coverage: {pr_web_cov}%")
|
||||
log(f"Base webview coverage: {base_web_cov}%")
|
||||
log(f"Webview coverage change: {'+' if not web_decreased else '-'}{web_diff}%")
|
||||
|
||||
# Generate and output warnings
|
||||
warnings = generate_warnings(
|
||||
base_ext_cov, pr_ext_cov, ext_decreased, ext_diff,
|
||||
base_web_cov, pr_web_cov, web_decreased, web_diff
|
||||
)
|
||||
output_warnings(warnings)
|
||||
|
||||
# Generate comment
|
||||
log("=== Generating comment ===")
|
||||
comment = generate_comment(
|
||||
base_ext_cov, pr_ext_cov, str(ext_decreased).lower(), ext_diff,
|
||||
base_web_cov, pr_web_cov, str(web_decreased).lower(), web_diff
|
||||
)
|
||||
|
||||
# Save comment to file
|
||||
with open("coverage_comment.md", "w") as f:
|
||||
f.write(comment)
|
||||
|
||||
# Post comment if PR number is provided
|
||||
if args.pr_number:
|
||||
log(f"=== Posting comment to PR #{args.pr_number} ===")
|
||||
post_comment("coverage_comment.md", args.pr_number, args.repo, args.token)
|
||||
|
||||
# Output results for GitHub Actions
|
||||
output_github_results(
|
||||
pr_ext_cov, pr_web_cov, base_ext_cov, base_web_cov,
|
||||
ext_decreased, ext_diff, web_decreased, web_diff
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
log(f"ERROR in process_coverage_workflow: {e}")
|
||||
traceback.print_exc()
|
||||
|
||||
# Try to output results even if there was an error
|
||||
try:
|
||||
output_github_results(
|
||||
pr_ext_cov, pr_web_cov, base_ext_cov, base_web_cov,
|
||||
ext_decreased, ext_diff, web_decreased, web_diff
|
||||
)
|
||||
except Exception as e2:
|
||||
log(f"ERROR outputting GitHub results: {e2}")
|
||||
@@ -22,6 +22,7 @@ Environment Variables:
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
CHANGELOG_PATH = os.environ.get("CHANGELOG_PATH", "CHANGELOG.md")
|
||||
VERSION = os.environ['VERSION']
|
||||
@@ -31,49 +32,72 @@ NEW_CONTENT = os.environ.get("NEW_CONTENT", "")
|
||||
def overwrite_changelog_section(changelog_text: str, new_content: str):
|
||||
# Find the section for the specified version
|
||||
version_pattern = f"## {VERSION}\n"
|
||||
unformmatted_prev_version_pattern = f"## {PREV_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}")
|
||||
|
||||
notes_start_index = changelog_text.find(version_pattern) + 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 or unformmatted_prev_version_pattern in changelog_text) else len(changelog_text)
|
||||
# Try both unbracketed and bracketed version patterns
|
||||
version_index = changelog_text.find(version_pattern)
|
||||
if version_index == -1:
|
||||
version_index = changelog_text.find(bracketed_version_pattern)
|
||||
if version_index == -1:
|
||||
# If version not found, add it at the top (after the first line)
|
||||
first_newline = changelog_text.find('\n')
|
||||
if first_newline == -1:
|
||||
# If no newline found, just prepend
|
||||
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
|
||||
|
||||
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:
|
||||
return changelog_text[:notes_start_index] + f"{new_content}\n" + changelog_text[notes_end_index:]
|
||||
else:
|
||||
changeset_lines = changelog_text[notes_start_index:notes_end_index].split("\n")
|
||||
filtered_lines = []
|
||||
for line in changeset_lines:
|
||||
# If the previous line is a changeset format
|
||||
if len(filtered_lines) > 1 and filtered_lines[-1].startswith("### "):
|
||||
# Remove the last two lines from the filted_lines
|
||||
filtered_lines.pop()
|
||||
filtered_lines.pop()
|
||||
else:
|
||||
filtered_lines.append(line.strip())
|
||||
|
||||
# Prepend a new line to the first line of filtered_lines
|
||||
if filtered_lines:
|
||||
filtered_lines[0] = "\n" + filtered_lines[0]
|
||||
|
||||
# Print filted_lines wiht a "\n" at the end of each line
|
||||
for line in filtered_lines:
|
||||
print(line.strip())
|
||||
|
||||
parsed_lines = "\n".join(line for line in filtered_lines)
|
||||
# Ensure we have at least 2 lines before removing them
|
||||
if len(changeset_lines) < 2:
|
||||
print("Warning: Changeset content has fewer than 2 lines")
|
||||
parsed_lines = "\n".join(changeset_lines)
|
||||
else:
|
||||
# Remove the first two lines from the regular changeset format, ex: \n### Patch Changes
|
||||
parsed_lines = "\n".join(changeset_lines[2:])
|
||||
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
|
||||
|
||||
with open(CHANGELOG_PATH, 'r') as f:
|
||||
changelog_content = f.read()
|
||||
try:
|
||||
print(f"Reading changelog from: {CHANGELOG_PATH}")
|
||||
with open(CHANGELOG_PATH, 'r') as f:
|
||||
changelog_content = f.read()
|
||||
|
||||
new_changelog = overwrite_changelog_section(changelog_content, NEW_CONTENT)
|
||||
# print("----------------------------------------------------------------------------------")
|
||||
# print(new_changelog)
|
||||
# print("----------------------------------------------------------------------------------")
|
||||
# Write back to CHANGELOG.md
|
||||
with open(CHANGELOG_PATH, 'w') as f:
|
||||
f.write(new_changelog)
|
||||
print(f"Changelog content length: {len(changelog_content)} characters")
|
||||
print("First 200 characters of changelog:")
|
||||
print(changelog_content[:200])
|
||||
print("----------------------------------------------------------------------------------")
|
||||
|
||||
print(f"{CHANGELOG_PATH} updated successfully!")
|
||||
new_changelog = overwrite_changelog_section(changelog_content, NEW_CONTENT)
|
||||
|
||||
print("New changelog content:")
|
||||
print("----------------------------------------------------------------------------------")
|
||||
print(new_changelog)
|
||||
print("----------------------------------------------------------------------------------")
|
||||
|
||||
print(f"Writing updated changelog back to: {CHANGELOG_PATH}")
|
||||
with open(CHANGELOG_PATH, 'w') as f:
|
||||
f.write(new_changelog)
|
||||
|
||||
print(f"{CHANGELOG_PATH} updated successfully!")
|
||||
|
||||
except FileNotFoundError:
|
||||
print(f"Error: Changelog file not found at {CHANGELOG_PATH}")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"Error updating changelog: {str(e)}")
|
||||
print(f"Current working directory: {os.getcwd()}")
|
||||
sys.exit(1)
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for coverage_check script.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
import subprocess
|
||||
import tempfile
|
||||
from unittest.mock import patch, MagicMock, call, mock_open
|
||||
|
||||
# Add parent directory to path so we can import coverage modules
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||
from coverage_check import extract_coverage, compare_coverage, set_verbose, generate_comment, post_comment, set_github_output
|
||||
from coverage_check.util import log, file_exists, get_file_size, list_directory
|
||||
|
||||
|
||||
class TestCoverage(unittest.TestCase):
|
||||
# Class variables to store coverage files
|
||||
temp_dir = None
|
||||
extension_coverage_file = None
|
||||
webview_coverage_file = None
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""Set up test environment once for all tests."""
|
||||
# Create temporary directory for test files
|
||||
cls.temp_dir = tempfile.TemporaryDirectory()
|
||||
cls.extension_coverage_file = os.path.join(cls.temp_dir.name, 'extension_coverage.txt')
|
||||
cls.webview_coverage_file = os.path.join(cls.temp_dir.name, 'webview_coverage.txt')
|
||||
|
||||
# Run actual tests to generate coverage reports
|
||||
cls.generate_coverage_reports()
|
||||
|
||||
# Verify files exist and are not empty
|
||||
assert os.path.exists(cls.extension_coverage_file), \
|
||||
f"Extension coverage file {cls.extension_coverage_file} does not exist"
|
||||
assert os.path.getsize(cls.extension_coverage_file) > 0, \
|
||||
f"Extension coverage file {cls.extension_coverage_file} is empty"
|
||||
assert os.path.exists(cls.webview_coverage_file), \
|
||||
f"Webview coverage file {cls.webview_coverage_file} does not exist"
|
||||
assert os.path.getsize(cls.webview_coverage_file) > 0, \
|
||||
f"Webview coverage file {cls.webview_coverage_file} is empty"
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
"""Clean up test environment after all tests."""
|
||||
if cls.temp_dir:
|
||||
cls.temp_dir.cleanup()
|
||||
|
||||
@classmethod
|
||||
def generate_coverage_reports(cls):
|
||||
"""Generate real coverage reports by running tests."""
|
||||
log("Generating coverage reports (this may take a while)...")
|
||||
|
||||
# Run extension tests with coverage
|
||||
try:
|
||||
# Get absolute paths
|
||||
root_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../..'))
|
||||
webview_dir = os.path.join(root_dir, 'webview-ui')
|
||||
|
||||
# Use xvfb-run on Linux
|
||||
if sys.platform.startswith('linux'):
|
||||
cmd = f"cd {root_dir} && xvfb-run -a npm run test:coverage > {cls.extension_coverage_file} 2>&1"
|
||||
else:
|
||||
cmd = f"cd {root_dir} && npm run test:coverage > {cls.extension_coverage_file} 2>&1"
|
||||
|
||||
log("Running extension tests...")
|
||||
log(f"Command: {cmd}")
|
||||
result = subprocess.run(cmd, shell=True, check=False, capture_output=True, text=True)
|
||||
log(f"Extension tests exit code: {result.returncode}")
|
||||
|
||||
# Run webview tests with coverage
|
||||
log("Running webview tests...")
|
||||
cmd = f"cd {webview_dir} && npm run test:coverage > {cls.webview_coverage_file} 2>&1"
|
||||
log(f"Command: {cmd}")
|
||||
result = subprocess.run(cmd, shell=True, check=False, capture_output=True, text=True)
|
||||
log(f"Webview tests exit code: {result.returncode}")
|
||||
|
||||
# Verify files were created
|
||||
if file_exists(cls.extension_coverage_file):
|
||||
ext_size = get_file_size(cls.extension_coverage_file)
|
||||
log(f"Extension coverage file created: {cls.extension_coverage_file} (size: {ext_size} bytes)")
|
||||
else:
|
||||
log(f"WARNING: Extension coverage file was not created: {cls.extension_coverage_file}")
|
||||
|
||||
if file_exists(cls.webview_coverage_file):
|
||||
web_size = get_file_size(cls.webview_coverage_file)
|
||||
log(f"Webview coverage file created: {cls.webview_coverage_file} (size: {web_size} bytes)")
|
||||
else:
|
||||
log(f"WARNING: Webview coverage file was not created: {cls.webview_coverage_file}")
|
||||
|
||||
log("Coverage reports generation completed.")
|
||||
except Exception as e:
|
||||
log(f"Error generating coverage reports: {e}")
|
||||
import traceback
|
||||
log(traceback.format_exc())
|
||||
|
||||
# Create empty files if tests fail
|
||||
log("Creating fallback coverage files...")
|
||||
with open(cls.extension_coverage_file, 'w') as f:
|
||||
f.write("No coverage data available")
|
||||
with open(cls.webview_coverage_file, 'w') as f:
|
||||
f.write("No coverage data available")
|
||||
|
||||
def test_extract_coverage(self):
|
||||
"""Test extract_coverage function with both extension and webview coverage."""
|
||||
# Check if verbose mode is enabled
|
||||
if '-v' in sys.argv or '--verbose' in sys.argv:
|
||||
set_verbose(True)
|
||||
|
||||
# Verify files exist before testing
|
||||
self.assertTrue(file_exists(self.extension_coverage_file),
|
||||
f"Extension coverage file does not exist: {self.extension_coverage_file}")
|
||||
self.assertTrue(file_exists(self.webview_coverage_file),
|
||||
f"Webview coverage file does not exist: {self.webview_coverage_file}")
|
||||
|
||||
# Log file sizes
|
||||
ext_size = get_file_size(self.extension_coverage_file)
|
||||
web_size = get_file_size(self.webview_coverage_file)
|
||||
log(f"Extension coverage file size: {ext_size} bytes")
|
||||
log(f"Webview coverage file size: {web_size} bytes")
|
||||
|
||||
# Test extension coverage
|
||||
log("Testing extension coverage extraction...")
|
||||
ext_coverage_pct = extract_coverage(self.extension_coverage_file, 'extension')
|
||||
|
||||
# Check that coverage percentage is a float
|
||||
self.assertIsInstance(ext_coverage_pct, float)
|
||||
|
||||
# Check that coverage percentage is between 0 and 100
|
||||
self.assertGreaterEqual(ext_coverage_pct, 0)
|
||||
self.assertLessEqual(ext_coverage_pct, 100)
|
||||
|
||||
# Log coverage percentage for debugging
|
||||
log(f"Extension coverage: {ext_coverage_pct}%")
|
||||
|
||||
# Test webview coverage
|
||||
log("Testing webview coverage extraction...")
|
||||
web_coverage_pct = extract_coverage(self.webview_coverage_file, 'webview')
|
||||
|
||||
# Convert to float if it's an integer
|
||||
if isinstance(web_coverage_pct, int):
|
||||
web_coverage_pct = float(web_coverage_pct)
|
||||
|
||||
# Check that coverage percentage is a float
|
||||
self.assertIsInstance(web_coverage_pct, float)
|
||||
|
||||
# Check that coverage percentage is between 0 and 100
|
||||
self.assertGreaterEqual(web_coverage_pct, 0)
|
||||
self.assertLessEqual(web_coverage_pct, 100)
|
||||
|
||||
# Log coverage percentage for debugging
|
||||
log(f"Webview coverage: {web_coverage_pct}%")
|
||||
|
||||
def test_compare_coverage(self):
|
||||
"""Test compare_coverage function."""
|
||||
# Test with coverage increase
|
||||
decreased, diff = compare_coverage(80, 90)
|
||||
self.assertFalse(decreased)
|
||||
self.assertEqual(diff, 10)
|
||||
|
||||
# Test with coverage decrease
|
||||
decreased, diff = compare_coverage(90, 80)
|
||||
self.assertTrue(decreased)
|
||||
self.assertEqual(diff, 10)
|
||||
|
||||
# Test with no change
|
||||
decreased, diff = compare_coverage(80, 80)
|
||||
self.assertFalse(decreased)
|
||||
self.assertEqual(diff, 0)
|
||||
|
||||
def test_generate_comment(self):
|
||||
"""Test generate_comment function."""
|
||||
comment = generate_comment(
|
||||
80, 90, 'false', 10,
|
||||
70, 75, 'false', 5
|
||||
)
|
||||
|
||||
# Check that comment contains expected sections
|
||||
self.assertIn('Coverage Report', comment)
|
||||
self.assertIn('Extension Coverage', comment)
|
||||
self.assertIn('Webview Coverage', comment)
|
||||
self.assertIn('Overall Assessment', comment)
|
||||
|
||||
# Check that comment contains coverage percentages
|
||||
self.assertIn('Base branch: 80%', comment)
|
||||
self.assertIn('PR branch: 90%', comment)
|
||||
self.assertIn('Base branch: 70%', comment)
|
||||
self.assertIn('PR branch: 75%', comment)
|
||||
|
||||
# Check that comment contains correct assessment
|
||||
self.assertIn('Coverage increased or remained the same', comment)
|
||||
self.assertIn('Test coverage has been maintained or improved', comment)
|
||||
|
||||
@patch('coverage_check.requests.get')
|
||||
@patch('coverage_check.requests.post')
|
||||
@patch('coverage_check.requests.patch')
|
||||
def test_post_comment_new(self, mock_patch, mock_post, mock_get):
|
||||
"""Test post_comment function when creating a new comment."""
|
||||
# Create a temporary comment file
|
||||
comment_file = os.path.join(self.temp_dir.name, 'comment.md')
|
||||
with open(comment_file, 'w') as f:
|
||||
f.write('<!-- COVERAGE_REPORT -->\nTest comment')
|
||||
|
||||
# Mock the API responses
|
||||
mock_get.return_value = MagicMock(status_code=200, json=lambda: [])
|
||||
mock_post.return_value = MagicMock(status_code=201)
|
||||
|
||||
# Test post_comment function
|
||||
post_comment(comment_file, '123', 'owner/repo', 'token')
|
||||
|
||||
# Check that the correct API calls were made
|
||||
mock_get.assert_called_once()
|
||||
mock_post.assert_called_once()
|
||||
mock_patch.assert_not_called()
|
||||
|
||||
@patch('coverage_check.requests.get')
|
||||
@patch('coverage_check.requests.post')
|
||||
@patch('coverage_check.requests.patch')
|
||||
def test_post_comment_update(self, mock_patch, mock_post, mock_get):
|
||||
"""Test post_comment function when updating an existing comment."""
|
||||
# Create a temporary comment file
|
||||
comment_file = os.path.join(self.temp_dir.name, 'comment.md')
|
||||
with open(comment_file, 'w') as f:
|
||||
f.write('<!-- COVERAGE_REPORT -->\nTest comment')
|
||||
|
||||
# Mock the API responses
|
||||
mock_get.return_value = MagicMock(
|
||||
status_code=200,
|
||||
json=lambda: [{'id': 456, 'body': '<!-- COVERAGE_REPORT -->\nOld comment'}]
|
||||
)
|
||||
mock_patch.return_value = MagicMock(status_code=200)
|
||||
|
||||
# Test post_comment function
|
||||
post_comment(comment_file, '123', 'owner/repo', 'token')
|
||||
|
||||
# Check that the correct API calls were made
|
||||
mock_get.assert_called_once()
|
||||
mock_patch.assert_called_once()
|
||||
mock_post.assert_not_called()
|
||||
|
||||
def test_set_github_output(self):
|
||||
"""Test set_github_output function."""
|
||||
# Capture stdout
|
||||
with patch('sys.stdout', new=MagicMock()) as mock_stdout:
|
||||
# Mock environment without GITHUB_OUTPUT
|
||||
with patch.dict('os.environ', {}, clear=True):
|
||||
set_github_output('test_name', 'test_value')
|
||||
|
||||
# Check that the correct output was printed to stdout
|
||||
mock_stdout.assert_has_calls([
|
||||
# GitHub Actions output format (deprecated method)
|
||||
call.write('::set-output name=test_name::test_value\n'),
|
||||
call.flush(),
|
||||
# Human readable format
|
||||
call.write('test_name: test_value\n'),
|
||||
call.flush()
|
||||
], any_order=False)
|
||||
|
||||
# Reset mock for next test
|
||||
mock_stdout.reset_mock()
|
||||
|
||||
# Test with GITHUB_OUTPUT environment variable
|
||||
with patch.dict('os.environ', {'GITHUB_OUTPUT': '/tmp/github_output'}), \
|
||||
patch('builtins.open', mock_open()) as mock_file:
|
||||
set_github_output('test_name', 'test_value')
|
||||
|
||||
# Check that file was written to
|
||||
mock_file.assert_called_once_with('/tmp/github_output', 'a')
|
||||
mock_file().write.assert_called_once_with('test_name=test_value\n')
|
||||
|
||||
# Check that human readable output was printed
|
||||
mock_stdout.assert_has_calls([
|
||||
call.write('test_name: test_value\n'),
|
||||
call.flush()
|
||||
], any_order=False)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,113 +0,0 @@
|
||||
name: Changeset Converter
|
||||
run-name: Changeset Conversion
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
|
||||
env:
|
||||
REPO_PATH: ${{ github.repository }}
|
||||
GIT_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || 'main' }}
|
||||
NODE_VERSION: 20.18.1
|
||||
|
||||
jobs:
|
||||
# Job 1: Create version bump PR when changesets are merged to main
|
||||
changeset-pr-version-bump:
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.merged == true &&
|
||||
github.event.pull_request.base.ref == 'main' &&
|
||||
github.actor != 'github-actions'
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check user for team affiliation
|
||||
id: team_check
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
uses: morfien101/actions-authorized-user@4a3cfbf0bcb3cafe4a71710a278920c5d94bb38b
|
||||
with:
|
||||
username: ${{ github.actor }}
|
||||
org: ${{ github.repository_owner }}
|
||||
team: "deployer"
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Check if user is authorized
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
run: |
|
||||
if [ "${{ steps.team_check.outputs.authorized }}" != "true" ]; then
|
||||
echo "User is not authorized to run this workflow."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Git Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ env.GIT_REF }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: "npm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install changeset
|
||||
|
||||
# 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: Create Changeset Pull Request
|
||||
if: steps.check-changesets.outputs.new_changesets != '0'
|
||||
uses: changesets/action@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 }}
|
||||
|
||||
# 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
|
||||
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 to Pull Request
|
||||
run: |
|
||||
git config user.name "github-actions"
|
||||
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 "--------------------------------------------------------------------------------"
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
git push origin $CURRENT_BRANCH
|
||||
@@ -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 }}
|
||||
@@ -0,0 +1,117 @@
|
||||
name: Check Changeset
|
||||
run-name: Check for Changeset in PR
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
jobs:
|
||||
check-changeset:
|
||||
# Skip draft PRs and dependabot PRs
|
||||
if: github.event.pull_request.draft == false && github.actor != 'dependabot[bot]'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Check for changeset
|
||||
id: check-changeset
|
||||
run: |
|
||||
# Debug info
|
||||
echo "Current directory: $(pwd)"
|
||||
echo "PR Base Ref: ${{ github.event.pull_request.base.ref }}"
|
||||
echo "PR Head Ref: ${{ github.event.pull_request.head.ref }}"
|
||||
echo "PR Head SHA: ${{ github.event.pull_request.head.sha }}"
|
||||
echo "Git status:"
|
||||
git status
|
||||
|
||||
# Get list of changed files
|
||||
git fetch origin ${{ github.event.pull_request.base.ref }}
|
||||
CHANGED_FILES=$(git diff --name-only origin/${{ github.event.pull_request.base.ref }} HEAD)
|
||||
echo "Changed files:"
|
||||
echo "$CHANGED_FILES"
|
||||
|
||||
# Check if any of the changed files are in docs/ or .github/
|
||||
echo "Checking if changes are docs-only..."
|
||||
DOCS_ONLY=true
|
||||
while IFS= read -r file; do
|
||||
if [[ ! "$file" =~ ^(docs/|.github/) ]]; then
|
||||
echo "Found non-docs change: $file"
|
||||
DOCS_ONLY=false
|
||||
break
|
||||
fi
|
||||
done <<< "$CHANGED_FILES"
|
||||
|
||||
# If changes are docs-only, skip changeset check
|
||||
if [ "$DOCS_ONLY" = true ]; then
|
||||
echo "All changes are in docs/ or .github/, skipping changeset check"
|
||||
exit 0
|
||||
else
|
||||
echo "Changes include non-docs files, checking for changeset..."
|
||||
fi
|
||||
|
||||
# Check if any changeset files are in the changed files
|
||||
echo "Checking for changeset files in changed files..."
|
||||
CHANGESET_IN_PR=false
|
||||
while IFS= read -r file; do
|
||||
if [[ "$file" =~ ^\.changeset/.*\.md$ && "$file" != ".changeset/README.md" && "$file" != ".changeset/config.json" ]]; then
|
||||
echo "Found changeset file in PR: $file"
|
||||
CHANGESET_IN_PR=true
|
||||
break
|
||||
fi
|
||||
done <<< "$CHANGED_FILES"
|
||||
|
||||
if [ "$CHANGESET_IN_PR" = false ]; then
|
||||
echo "No changeset files found in changed files. Changed files in .changeset/:"
|
||||
echo "$CHANGED_FILES" | grep "^\.changeset/" || true
|
||||
echo "::error::No changeset file found in PR changes. Please run 'npm run changeset' to create one."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Comment on PR
|
||||
if: failure()
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
with:
|
||||
script: |
|
||||
const message = `This PR requires a changeset since it includes user-facing changes. Please:
|
||||
|
||||
1. Run \`npm run changeset\` locally
|
||||
2. Choose the appropriate version bump:
|
||||
- \`major\` for breaking changes (1.0.0 → 2.0.0)
|
||||
- \`minor\` for new features (1.0.0 → 1.1.0)
|
||||
- \`patch\` for bug fixes (1.0.0 → 1.0.1)
|
||||
3. Write a clear description of your changes
|
||||
4. Commit the generated changeset file
|
||||
|
||||
Note: Documentation-only changes do not require a changeset.`;
|
||||
|
||||
// Get existing comments
|
||||
const comments = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number
|
||||
});
|
||||
|
||||
// Check if we already commented
|
||||
const botComment = comments.data.find(comment =>
|
||||
comment.user.login === 'github-actions[bot]' &&
|
||||
comment.body.includes('This PR requires a changeset')
|
||||
);
|
||||
|
||||
if (!botComment) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: message
|
||||
});
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
name: E2E Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize, ready_for_review]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
matrix_prep:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
steps:
|
||||
- id: set-matrix
|
||||
run: |
|
||||
echo 'matrix=[{"runner":"ubuntu"},{"runner":"windows"},{"runner":"macos"}]' >> $GITHUB_OUTPUT
|
||||
|
||||
e2e:
|
||||
needs: matrix_prep
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include: ${{ fromJson(needs.matrix_prep.outputs.matrix) }}
|
||||
runs-on: ${{ matrix.runner }}-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
# Cache root dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache root dependencies
|
||||
uses: actions/cache@v4
|
||||
id: root-cache
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
# Cache webview-ui dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache webview-ui dependencies
|
||||
uses: actions/cache@v4
|
||||
id: webview-cache
|
||||
with:
|
||||
path: webview-ui/node_modules
|
||||
key: ${{ runner.os }}-npm-webview-${{ hashFiles('webview-ui/package-lock.json') }}
|
||||
|
||||
# Cache VS Code installation
|
||||
- name: Cache VS Code
|
||||
uses: actions/cache@v4
|
||||
id: vscode-cache
|
||||
with:
|
||||
path: .vscode-test
|
||||
key: vscode-${{ runner.os }}-stable-${{ hashFiles('.vscode-test.mjs', 'package.json') }}
|
||||
restore-keys: |
|
||||
vscode-${{ runner.os }}-stable-
|
||||
|
||||
# Cache Playwright browsers
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v4
|
||||
id: playwright-cache
|
||||
with:
|
||||
path: |
|
||||
~/.cache/ms-playwright
|
||||
~/Library/Caches/ms-playwright
|
||||
~/AppData/Local/ms-playwright
|
||||
key: playwright-browsers-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: |
|
||||
playwright-browsers-${{ runner.os }}-
|
||||
|
||||
- name: Install root dependencies
|
||||
if: steps.root-cache.outputs.cache-hit != 'true'
|
||||
run: npm ci
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
if: steps.webview-cache.outputs.cache-hit != 'true'
|
||||
run: cd webview-ui && npm ci
|
||||
|
||||
- name: Install xvfb on Linux
|
||||
if: matrix.runner == 'ubuntu'
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb
|
||||
|
||||
# Run optimized E2E tests (eliminates redundant builds)
|
||||
- name: Run E2E tests - Linux
|
||||
if: matrix.runner == 'ubuntu'
|
||||
run: xvfb-run -a npm run test:e2e:optimal
|
||||
|
||||
- name: Run E2E tests - Non-Linux
|
||||
if: matrix.runner != 'ubuntu'
|
||||
run: npm run test:e2e:optimal
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ failure() }}
|
||||
with:
|
||||
name: playwright-recordings-${{ matrix.runner }}
|
||||
path: |
|
||||
test-results/playwright/
|
||||
@@ -1,75 +0,0 @@
|
||||
name: "Publish Nightly Release"
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 12 * * *' # 4 AM PST (UTC-8) = 12 UTC
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
checks: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
test:
|
||||
uses: ./.github/workflows/test.yml
|
||||
|
||||
publish:
|
||||
needs: test
|
||||
name: Publish Cline (Nightly) Extension
|
||||
if: github.repository == 'cline/cline'
|
||||
runs-on: ubuntu-latest
|
||||
environment: PublishNightly
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Check for recent commits
|
||||
run: |
|
||||
if [ $(git rev-list --count HEAD --since="24 hours ago") -eq 0 ]; then
|
||||
echo "No commits in last 24 hours, exiting"
|
||||
exit 0
|
||||
fi
|
||||
echo "Found recent commits, proceeding with build"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "lts/*"
|
||||
|
||||
# Cache root dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache root dependencies
|
||||
uses: actions/cache@v4
|
||||
id: root-cache
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
# Cache webview-ui dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache webview-ui dependencies
|
||||
uses: actions/cache@v4
|
||||
id: webview-cache
|
||||
with:
|
||||
path: webview-ui/node_modules
|
||||
key: ${{ runner.os }}-npm-webview-${{ hashFiles('webview-ui/package-lock.json') }}
|
||||
|
||||
- name: Install root dependencies
|
||||
if: steps.root-cache.outputs.cache-hit != 'true'
|
||||
run: npm ci --include=optional
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
if: steps.webview-cache.outputs.cache-hit != 'true'
|
||||
run: cd webview-ui && npm ci --include=optional
|
||||
|
||||
- name: Install Publishing Tools
|
||||
run: npm install -g @vscode/vsce ovsx
|
||||
|
||||
- name: Publish Extension as Pre-release
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
OVSX_PAT: ${{ secrets.OVSX_PAT }}
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
CLINE_ENVIRONMENT: production
|
||||
run: npm run publish:marketplace:nightly
|
||||
@@ -11,10 +11,6 @@ on:
|
||||
options:
|
||||
- pre-release
|
||||
- release
|
||||
tag:
|
||||
description: "Enter existing tag to publish (e.g., v3.1.2)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -34,13 +30,11 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.tag }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "lts/*"
|
||||
node-version: 20.15.1
|
||||
|
||||
# Cache root dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache root dependencies
|
||||
@@ -60,14 +54,14 @@ jobs:
|
||||
|
||||
- name: Install root dependencies
|
||||
if: steps.root-cache.outputs.cache-hit != 'true'
|
||||
run: npm ci --include=optional
|
||||
run: npm ci
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
if: steps.webview-cache.outputs.cache-hit != 'true'
|
||||
run: cd webview-ui && npm ci --include=optional
|
||||
run: cd webview-ui && npm ci
|
||||
|
||||
- name: Install Publishing Tools
|
||||
run: npm install -g @vscode/vsce ovsx
|
||||
run: npm install -g vsce ovsx
|
||||
|
||||
- name: Get Version
|
||||
id: get_version
|
||||
@@ -75,31 +69,22 @@ jobs:
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Validate Tag
|
||||
id: validate_tag
|
||||
- name: Create Git Tag
|
||||
id: create_tag
|
||||
run: |
|
||||
TAG="${{ github.event.inputs.tag }}"
|
||||
echo "tag=$TAG" >> $GITHUB_OUTPUT
|
||||
echo "Using existing tag: $TAG"
|
||||
|
||||
# Verify the tag exists
|
||||
if ! git rev-parse "$TAG" >/dev/null 2>&1; then
|
||||
echo "Error: Tag '$TAG' does not exist in the repository"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Tag '$TAG' validated successfully"
|
||||
VERSION=v${{ steps.get_version.outputs.version }}
|
||||
echo "tag=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Tagging with $VERSION"
|
||||
git tag "$VERSION"
|
||||
git push origin "$VERSION"
|
||||
|
||||
- name: Package and Publish Extension
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
OVSX_PAT: ${{ secrets.OVSX_PAT }}
|
||||
CLINE_ENVIRONMENT: production
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
run: |
|
||||
# Required to generate the .vsix
|
||||
vsce package --allow-package-secrets sendgrid --out "cline-${{ steps.get_version.outputs.version }}.vsix"
|
||||
vsce package --out "cline-${{ steps.get_version.outputs.version }}.vsix"
|
||||
|
||||
if [ "${{ github.event.inputs.release-type }}" = "pre-release" ]; then
|
||||
npm run publish:marketplace:prerelease
|
||||
@@ -121,7 +106,7 @@ jobs:
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ steps.validate_tag.outputs.tag }}
|
||||
tag_name: ${{ steps.create_tag.outputs.tag }}
|
||||
files: "*.vsix"
|
||||
# body: ${{ steps.changelog.outputs.content }}
|
||||
generate_release_notes: true
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
# This workflow will only label and/or close 30 issues at a time in order to avoid exceeding a rate limit.
|
||||
# More info: https://docs.github.com/en/actions/use-cases-and-examples/project-management/closing-inactive-issues
|
||||
name: Close inactive issues
|
||||
on:
|
||||
schedule:
|
||||
- cron: "30 1 * * *"
|
||||
|
||||
jobs:
|
||||
close-issues:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
days-before-issue-stale: 60
|
||||
days-before-issue-close: 14
|
||||
stale-issue-label: "stale"
|
||||
stale-issue-message: "This issue is stale because it has been open for 60 days with no activity."
|
||||
close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale."
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
exempt-issue-labels: "pinned,security"
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -1,32 +0,0 @@
|
||||
name: Test Stale Issues Workflow
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
days-before-stale:
|
||||
description: "Days before an issue becomes stale"
|
||||
required: true
|
||||
default: "1"
|
||||
days-before-close:
|
||||
description: "Days before a stale issue is closed"
|
||||
required: true
|
||||
default: "1"
|
||||
|
||||
jobs:
|
||||
test-stale:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@28ca103
|
||||
with:
|
||||
days-before-issue-stale: ${{ github.event.inputs.days-before-stale }}
|
||||
days-before-issue-close: ${{ github.event.inputs.days-before-close }}
|
||||
stale-issue-label: "stale"
|
||||
stale-issue-message: "This issue is stale because it has been open for ${{ github.event.inputs.days-before-stale }} days with no activity."
|
||||
close-issue-message: "This issue was closed because it has been inactive for ${{ github.event.inputs.days-before-close }} days since being marked as stale."
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
exempt-issue-labels: "pinned,security"
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
debug-only: true
|
||||
+12
-206
@@ -1,9 +1,6 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches:
|
||||
@@ -17,54 +14,8 @@ permissions:
|
||||
pull-requests: write # Needed to add comments/annotations to PRs
|
||||
|
||||
jobs:
|
||||
quality-checks:
|
||||
runs-on: ubuntu-latest
|
||||
name: Quality Checks
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Cache root dependencies
|
||||
uses: actions/cache@v4
|
||||
id: root-cache
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- name: Cache webview-ui dependencies
|
||||
uses: actions/cache@v4
|
||||
id: webview-cache
|
||||
with:
|
||||
path: webview-ui/node_modules
|
||||
key: ${{ runner.os }}-npm-webview-${{ hashFiles('webview-ui/package-lock.json') }}
|
||||
|
||||
- name: Install root dependencies
|
||||
if: steps.root-cache.outputs.cache-hit != 'true'
|
||||
run: npm ci
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
if: steps.webview-cache.outputs.cache-hit != 'true'
|
||||
run: cd webview-ui && npm ci
|
||||
|
||||
- name: Run Quality Checks (Parallel)
|
||||
run: npm run ci:check-all
|
||||
|
||||
test:
|
||||
needs: quality-checks
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
name: ${{ matrix.os == 'ubuntu-latest' && 'test' || format('test ({0})', matrix.os) }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
@@ -72,8 +23,9 @@ jobs:
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 20.15.1
|
||||
|
||||
# Cache root dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache root dependencies
|
||||
uses: actions/cache@v4
|
||||
id: root-cache
|
||||
@@ -81,6 +33,7 @@ jobs:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
# Cache webview-ui dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache webview-ui dependencies
|
||||
uses: actions/cache@v4
|
||||
id: webview-cache
|
||||
@@ -96,161 +49,14 @@ jobs:
|
||||
if: steps.webview-cache.outputs.cache-hit != 'true'
|
||||
run: cd webview-ui && npm ci
|
||||
|
||||
- name: Set up NPM on Windows
|
||||
if: runner.os == 'Windows'
|
||||
run: |
|
||||
npm config set script-shell "C:\\Program Files\\Git\\bin\\bash.exe"
|
||||
- name: Type Check
|
||||
run: npm run check-types
|
||||
|
||||
# Build the extension and tests (without redundant checks)
|
||||
- name: Build Tests and Extension
|
||||
id: build_step
|
||||
run: npm run ci:build
|
||||
- name: ESLint Check
|
||||
run: npm run lint
|
||||
|
||||
- name: Unit Tests with coverage - Linux
|
||||
id: unit_tests_linux
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os == 'Linux' }}
|
||||
run: |
|
||||
npx nyc --nycrc-path .nycrc.unit.json --reporter=lcov npm run test:unit
|
||||
- name: Prettier / Format Check
|
||||
run: npm run format
|
||||
|
||||
- name: Unit Tests - Non-Linux
|
||||
id: unit_tests_non_linux
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os != 'Linux' }}
|
||||
run: |
|
||||
npm run test:unit
|
||||
|
||||
- name: Extension Integration Tests - Linux
|
||||
id: integration_tests_linux
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os == 'Linux' }}
|
||||
run: xvfb-run -a npm run test:coverage
|
||||
|
||||
- name: Extension Integration Tests - Non-Linux
|
||||
id: integration_tests_non_linux
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os != 'Linux' }}
|
||||
run: npm run test:integration
|
||||
|
||||
- name: Webview Tests with Coverage
|
||||
id: webview_tests
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
run: |
|
||||
cd webview-ui
|
||||
npm run test:coverage
|
||||
|
||||
- name: Save Coverage Reports
|
||||
uses: actions/upload-artifact@v4
|
||||
# Only upload artifacts on Linux - We only need coverage from one OS
|
||||
if: runner.os == 'Linux'
|
||||
with:
|
||||
name: pr-coverage-reports
|
||||
path: |
|
||||
coverage-unit/lcov.info
|
||||
webview-ui/coverage/lcov.info
|
||||
|
||||
test-platform-integration:
|
||||
needs: quality-checks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Cache root dependencies
|
||||
uses: actions/cache@v4
|
||||
id: root-cache
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- name: Cache webview-ui dependencies
|
||||
uses: actions/cache@v4
|
||||
id: webview-cache
|
||||
with:
|
||||
path: webview-ui/node_modules
|
||||
key: ${{ runner.os }}-npm-webview-${{ hashFiles('webview-ui/package-lock.json') }}
|
||||
|
||||
# Cache testing-platform dependencies
|
||||
- name: Cache testing-platform dependencies
|
||||
uses: actions/cache@v4
|
||||
id: testing-platform-cache
|
||||
with:
|
||||
path: testing-platform/node_modules
|
||||
key: ${{ runner.os }}-npm-testing-platform-${{ hashFiles('testing-platform/package-lock.json') }}
|
||||
|
||||
- name: Install root dependencies
|
||||
if: steps.root-cache.outputs.cache-hit != 'true'
|
||||
run: npm ci
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
if: steps.webview-cache.outputs.cache-hit != 'true'
|
||||
run: cd webview-ui && npm ci
|
||||
|
||||
- name: Compile standalone
|
||||
run: npm run compile-standalone
|
||||
|
||||
- name: Install testing platform dependencies
|
||||
if: steps.testing-platform-cache.outputs.cache-hit != 'true'
|
||||
run: cd testing-platform && npm ci
|
||||
|
||||
- name: Running testing platform integration spec tests
|
||||
continue-on-error: true
|
||||
timeout-minutes: 7
|
||||
# Temporarily wrapping the test command to always return a neutral exit code.
|
||||
# This prevents the job from showing as failed and avoids distracting developers
|
||||
# until the integration tests are ready to be enforced.
|
||||
run: |
|
||||
npm run test:tp-orchestrator -- tests/specs/ --count=1 --coverage || true
|
||||
|
||||
- name: Save Coverage Reports
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test-platform-integration-core-coverage
|
||||
path: coverage/**/lcov.info
|
||||
|
||||
qlty:
|
||||
needs: [test, test-platform-integration]
|
||||
runs-on: ubuntu-latest
|
||||
# Run on PRs to main, pushes to main, and manual dispatches
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download unit tests coverage reports
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: pr-coverage-reports
|
||||
path: .
|
||||
|
||||
- name: Upload core unit tests coverage to Qlty
|
||||
uses: qltysh/qlty-action/coverage@v2
|
||||
with:
|
||||
token: ${{ secrets.QLTY_COVERAGE_TOKEN }}
|
||||
# we can merge multiple files if necessary
|
||||
files: |
|
||||
coverage-unit/lcov.info
|
||||
tag: unit:core
|
||||
|
||||
- name: Upload webview-ui unit tests coverage to Qlty
|
||||
uses: qltysh/qlty-action/coverage@v2
|
||||
with:
|
||||
token: ${{ secrets.QLTY_COVERAGE_TOKEN }}
|
||||
# we can merge multiple files if necessary
|
||||
files: |
|
||||
webview-ui/coverage/lcov.info
|
||||
tag: unit:webview-ui
|
||||
add-prefix: webview-ui/
|
||||
|
||||
- name: Download test platform integration core coverage artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: test-platform-integration-core-coverage
|
||||
path: integration-core-coverage-reports
|
||||
|
||||
- name: Upload core integration tests coverage to Qlty
|
||||
uses: qltysh/qlty-action/coverage@v2
|
||||
with:
|
||||
token: ${{ secrets.QLTY_COVERAGE_TOKEN }}
|
||||
files: integration-core-coverage-reports/**/lcov.info
|
||||
tag: integration:core
|
||||
- name: Extension Tests
|
||||
run: xvfb-run -a npm run test
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
name: Trigger Jetbrains Plugin <-> Cline Tests
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
group: jetbrains-trigger-${{ github.event.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
trigger-integration-test:
|
||||
name: Run Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Generate GitHub App Token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: 1998650
|
||||
private-key: ${{ secrets.CLINE_JETBRAINS_WORKFLOW_KEY }}
|
||||
owner: cline
|
||||
repositories: intellij-plugin
|
||||
|
||||
- name: Trigger IntelliJ Plugin Integration Test
|
||||
run: |
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer ${{ steps.app-token.outputs.token }}" \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
-H "User-Agent: cline-pr-trigger" \
|
||||
-H "Content-Type: application/json" \
|
||||
https://api.github.com/repos/cline/intellij-plugin/dispatches \
|
||||
-d @- <<EOF
|
||||
{
|
||||
"event_type": "cline-pr-check",
|
||||
"client_payload": {
|
||||
"pr_number": "${{ github.event.number }}",
|
||||
"branch_name": "${{ github.head_ref }}",
|
||||
"action": "${{ github.event.action }}",
|
||||
"sha": "${{ github.event.pull_request.head.sha }}",
|
||||
"pr_title": ${{ toJSON(github.event.pull_request.title) }},
|
||||
"pr_url": "${{ github.event.pull_request.html_url }}"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
- name: Log trigger details
|
||||
run: |
|
||||
echo "Triggered IntelliJ Plugin integration test for:"
|
||||
echo " PR #${{ github.event.number }}: ${{ github.event.pull_request.title }}"
|
||||
echo " Branch: ${{ github.head_ref }}"
|
||||
echo " Action: ${{ github.event.action }}"
|
||||
echo " SHA: ${{ github.event.pull_request.head.sha }}"
|
||||
+1
-25
@@ -1,36 +1,12 @@
|
||||
out
|
||||
dist
|
||||
dist-standalone
|
||||
node_modules
|
||||
tmp
|
||||
.vscode-test/
|
||||
*.vsix
|
||||
|
||||
.DS_Store
|
||||
.idea
|
||||
|
||||
pnpm-lock.yaml
|
||||
|
||||
.clineignore
|
||||
.venv
|
||||
.actrc
|
||||
|
||||
webview-ui/src/**/*.js
|
||||
webview-ui/src/**/*.js.map
|
||||
|
||||
# Ignore coverage directories and files
|
||||
coverage
|
||||
coverage-unit
|
||||
.nyc_output
|
||||
# But don't ignore the coverage scripts in .github/scripts/
|
||||
!.github/scripts/coverage/
|
||||
|
||||
*evals.env
|
||||
|
||||
## Generated files ##
|
||||
src/generated/
|
||||
src/shared/proto/
|
||||
webview-ui/src/services/grpc-client.ts
|
||||
|
||||
# E2E Tests
|
||||
test-results
|
||||
.clineignore
|
||||
Regular → Executable
+17
-1
@@ -1 +1,17 @@
|
||||
lint-staged
|
||||
echo "Running pre-commit checks..."
|
||||
|
||||
# Run ESLint
|
||||
echo "Running ESLint..."
|
||||
npm run lint || {
|
||||
echo "❌ ESLint check failed. Please fix the errors and try committing again."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Run Prettier
|
||||
echo "Running Prettier..."
|
||||
npm run format || {
|
||||
echo "❌ Prettier check failed. Run 'npm run format:fix' to automatically fix formatting issues."
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "✅ All checks passed!"
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"extension": [
|
||||
"ts"
|
||||
],
|
||||
"spec": [
|
||||
"src/**/__tests__/*.ts"
|
||||
],
|
||||
"require": [
|
||||
"ts-node/register",
|
||||
"source-map-support/register",
|
||||
"./src/test/requires.ts"
|
||||
],
|
||||
"recursive": true,
|
||||
"exit": true
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
{
|
||||
"all": true,
|
||||
"check-coverage": false,
|
||||
"reporter": [
|
||||
"text",
|
||||
"lcov"
|
||||
],
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"**/*.d.ts",
|
||||
|
||||
"**/*.{test,spec}.{js,jsx,ts,tsx,mjs,cjs}",
|
||||
"**/__tests__/**",
|
||||
"**/test/**",
|
||||
"**/tests/**",
|
||||
"**/.nyc_output/**",
|
||||
"**/.vscode-test/**",
|
||||
"**/tests-results/**",
|
||||
"src/test/**",
|
||||
|
||||
"src/generated/**",
|
||||
|
||||
"**/node_modules/**",
|
||||
"**/dist/**",
|
||||
"**/out/**",
|
||||
"**/build/**",
|
||||
"**/coverage/**",
|
||||
"**/coverage-unit/**",
|
||||
"**/proto/**",
|
||||
|
||||
"**/*.{config,setup}.{js,ts,mjs,cjs}",
|
||||
"**/vite-env.d.ts",
|
||||
|
||||
"**/*.{css,scss,sass,less,styl}",
|
||||
"**/*.{svg,png,jpg,jpeg,gif,ico}",
|
||||
"**/*.{json,yaml,yml}"
|
||||
],
|
||||
"extension": [
|
||||
".ts",
|
||||
".js"
|
||||
],
|
||||
"cache": true,
|
||||
"sourceMap": true,
|
||||
"instrument": true,
|
||||
"report-dir": "./coverage-unit"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
dist/
|
||||
node_modules
|
||||
webview-ui/build/
|
||||
*.md
|
||||
package-lock.json
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"tabWidth": 4,
|
||||
"useTabs": true,
|
||||
"printWidth": 130,
|
||||
"semi": false,
|
||||
"bracketSameLine": true
|
||||
}
|
||||
+1
-5
@@ -2,14 +2,10 @@ import { defineConfig } from "@vscode/test-cli"
|
||||
import path from "path"
|
||||
|
||||
export default defineConfig({
|
||||
files: "{out/**/*.test.js,src/**/*.test.js,!src/test/e2e/**/*.test.js,!out/src/test/e2e/**/*.test.js}",
|
||||
files: "{out/**/*.test.js,src/**/*.test.js}",
|
||||
mocha: {
|
||||
ui: "bdd",
|
||||
timeout: 20000, // Maximum time (in ms) that a test can run before failing
|
||||
/** Set up alias path resolution during tests
|
||||
* @See {@link file://./test-setup.js}
|
||||
*/
|
||||
require: ["./test-setup.js"],
|
||||
},
|
||||
workspaceFolder: "test-workspace",
|
||||
version: "stable",
|
||||
|
||||
Vendored
+1
-6
@@ -1,10 +1,5 @@
|
||||
{
|
||||
// See http://go.microsoft.com/fwlink/?LinkId=827846
|
||||
// for the documentation about the extensions.json format
|
||||
"recommendations": [
|
||||
"connor4312.esbuild-problem-matchers",
|
||||
"ms-vscode.extension-test-runner",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"biomejs.biome"
|
||||
]
|
||||
"recommendations": ["dbaeumer.vscode-eslint", "connor4312.esbuild-problem-matchers", "ms-vscode.extension-test-runner"]
|
||||
}
|
||||
|
||||
Vendored
+4
-147
@@ -6,159 +6,16 @@
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Run Extension (production)",
|
||||
"name": "Run Extension",
|
||||
"type": "extensionHost",
|
||||
"request": "launch",
|
||||
"args": [
|
||||
"--extensionDevelopmentPath=${workspaceFolder}",
|
||||
"--disable-workspace-trust",
|
||||
"--disable-extensions", // Avoid conflicts with installed extensions
|
||||
"${workspaceFolder}"
|
||||
],
|
||||
"outFiles": [
|
||||
"${workspaceFolder}/dist/**/*.js"
|
||||
],
|
||||
"args": ["--extensionDevelopmentPath=${workspaceFolder}"],
|
||||
"outFiles": ["${workspaceFolder}/dist/**/*.js"],
|
||||
"preLaunchTask": "${defaultBuildTask}",
|
||||
"env": {
|
||||
"IS_DEV": "true",
|
||||
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}",
|
||||
"CLINE_ENVIRONMENT": "production"
|
||||
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Run Extension (staging)",
|
||||
"type": "extensionHost",
|
||||
"request": "launch",
|
||||
"args": [
|
||||
"--extensionDevelopmentPath=${workspaceFolder}",
|
||||
"--disable-workspace-trust",
|
||||
"--disable-extensions", // Avoid conflicts with installed extensions
|
||||
"${workspaceFolder}"
|
||||
],
|
||||
"outFiles": [
|
||||
"${workspaceFolder}/dist/**/*.js"
|
||||
],
|
||||
"preLaunchTask": "${defaultBuildTask}",
|
||||
"env": {
|
||||
"IS_DEV": "true",
|
||||
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}",
|
||||
"CLINE_ENVIRONMENT": "staging"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Run Extension (local)",
|
||||
"type": "extensionHost",
|
||||
"request": "launch",
|
||||
"args": [
|
||||
"--extensionDevelopmentPath=${workspaceFolder}",
|
||||
"--disable-workspace-trust",
|
||||
"--disable-extensions", // Avoid conflicts with installed extensions
|
||||
"${workspaceFolder}"
|
||||
],
|
||||
"outFiles": [
|
||||
"${workspaceFolder}/dist/**/*.js"
|
||||
],
|
||||
"preLaunchTask": "${defaultBuildTask}",
|
||||
"env": {
|
||||
"IS_DEV": "true",
|
||||
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}",
|
||||
"CLINE_ENVIRONMENT": "local"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Run Extension (Fresh Install Mode)",
|
||||
"type": "extensionHost",
|
||||
"request": "launch",
|
||||
"runtimeExecutable": "${execPath}",
|
||||
"args": [
|
||||
"--user-data-dir=${workspaceFolder}/dist/tmp/user",
|
||||
"--profile-temp",
|
||||
"--sync=off",
|
||||
"--disable-extensions", // Avoid conflicts with installed extensions
|
||||
"--extensionDevelopmentPath=${workspaceFolder}",
|
||||
"${workspaceFolder}"
|
||||
],
|
||||
"outFiles": [
|
||||
"${workspaceFolder}/dist/**/*.js"
|
||||
],
|
||||
"preLaunchTask": "clean-tmp-user",
|
||||
"internalConsoleOptions": "openOnSessionStart",
|
||||
"postDebugTask": "stop",
|
||||
"env": {
|
||||
"IS_DEV": "true",
|
||||
"TEMP_PROFILE": "true",
|
||||
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}",
|
||||
"CLINE_ENVIRONMENT": "production"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Debug Test Standalone Core Api Server (test:sca-server)",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"sourceMaps": true,
|
||||
"resolveSourceMapLocations": [
|
||||
"${workspaceFolder}/**",
|
||||
"!**/node_modules/**"
|
||||
],
|
||||
"cwd": "${workspaceFolder}",
|
||||
"outFiles": [
|
||||
"${workspaceFolder}/dist/**/*.js",
|
||||
"${workspaceFolder}/dist-standalone/**/*.js"
|
||||
],
|
||||
"preLaunchTask": "compile-standalone",
|
||||
"runtimeExecutable": "npx",
|
||||
"runtimeArgs": [
|
||||
"tsx"
|
||||
],
|
||||
"program": "scripts/test-standalone-core-api-server.ts",
|
||||
"env": {
|
||||
"PROTOBUS_PORT": "26040",
|
||||
"HOSTBRIDGE_PORT": "26041",
|
||||
"WORKSPACE_DIR": "${workspaceFolder}",
|
||||
"E2E_TEST": "true",
|
||||
"CLINE_ENVIRONMENT": "local"
|
||||
},
|
||||
"console": "integratedTerminal",
|
||||
"internalConsoleOptions": "neverOpen"
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Debug Current Test File",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"sourceMaps": true,
|
||||
"resolveSourceMapLocations": [
|
||||
"${workspaceFolder}/**",
|
||||
"!**/node_modules/**"
|
||||
],
|
||||
"cwd": "${workspaceFolder}",
|
||||
"runtimeExecutable": "npx",
|
||||
"runtimeArgs": [
|
||||
"mocha"
|
||||
],
|
||||
"args": [
|
||||
"--require",
|
||||
"ts-node/register",
|
||||
"--require",
|
||||
"source-map-support/register",
|
||||
"--require",
|
||||
"./src/test/requires.ts",
|
||||
"--exit",
|
||||
"${file}"
|
||||
],
|
||||
"env": {
|
||||
"TS_NODE_PROJECT": "./tsconfig.unit-test.json",
|
||||
"NODE_ENV": "test",
|
||||
"IS_DEV": "true",
|
||||
"CLINE_ENVIRONMENT": "local"
|
||||
},
|
||||
"console": "integratedTerminal",
|
||||
"internalConsoleOptions": "openOnSessionStart"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
+2
-20
@@ -6,26 +6,8 @@
|
||||
},
|
||||
"search.exclude": {
|
||||
"out": true, // set this to false to include "out" folder in search results
|
||||
"dist": true, // set this to false to include "dist" folder in search results,
|
||||
"node_modules": true,
|
||||
"dist-standalone": true
|
||||
"dist": true // set this to false to include "dist" folder in search results
|
||||
},
|
||||
// Turn off tsc task auto detection since we have the necessary tasks as npm scripts
|
||||
"typescript.tsc.autoDetect": "off",
|
||||
"typescript.preferences.quoteStyle": "double",
|
||||
// Protobuf settings
|
||||
"protoc": {
|
||||
"options": [
|
||||
"--proto_path=proto"
|
||||
]
|
||||
},
|
||||
// Enable Lint and format using Biome
|
||||
"biome.enabled": true,
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.biome": "explicit",
|
||||
"source.removeUnused.biome": "always",
|
||||
"source.removeUnusedImports": "always",
|
||||
"source.organizeImports.biome": "always"
|
||||
}
|
||||
"typescript.tsc.autoDetect": "off"
|
||||
}
|
||||
|
||||
Vendored
+13
-164
@@ -3,62 +3,17 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "compile-standalone",
|
||||
"type": "npm",
|
||||
"script": "compile-standalone",
|
||||
"group": "build",
|
||||
"problemMatcher": [],
|
||||
"presentation": {
|
||||
"reveal": "always"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "npm: protos",
|
||||
"type": "npm",
|
||||
"script": "protos",
|
||||
"problemMatcher": [],
|
||||
"isBackground": false,
|
||||
"presentation": {
|
||||
"reveal": "always"
|
||||
},
|
||||
"options": {
|
||||
"env": {
|
||||
"IS_DEV": "true"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "watch",
|
||||
"dependsOn": [
|
||||
"npm: protos",
|
||||
"npm: build:webview",
|
||||
"npm: dev:webview",
|
||||
"npm: watch:tsc",
|
||||
"npm: watch:esbuild"
|
||||
],
|
||||
"dependsOn": ["npm: build:webview", "npm: dev:webview", "npm: watch:tsc", "npm: watch:esbuild"],
|
||||
"presentation": {
|
||||
"reveal": "always"
|
||||
"reveal": "never"
|
||||
},
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "watch:test",
|
||||
"dependsOn": [
|
||||
"npm: protos",
|
||||
"npm: build:webview:test",
|
||||
"npm: dev:webview",
|
||||
"npm: watch:tsc",
|
||||
"npm: watch:esbuild:test"
|
||||
],
|
||||
"presentation": {
|
||||
"reveal": "always"
|
||||
},
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "build:webview",
|
||||
@@ -66,12 +21,10 @@
|
||||
"problemMatcher": [],
|
||||
"isBackground": true,
|
||||
"label": "npm: build:webview",
|
||||
"dependsOn": [
|
||||
"npm: protos"
|
||||
],
|
||||
"presentation": {
|
||||
"group": "watch",
|
||||
"reveal": "always"
|
||||
"reveal": "never",
|
||||
"close": true
|
||||
},
|
||||
"options": {
|
||||
"env": {
|
||||
@@ -79,27 +32,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "build:webview:test",
|
||||
"group": "build",
|
||||
"problemMatcher": [],
|
||||
"isBackground": true,
|
||||
"label": "npm: build:webview:test",
|
||||
"dependsOn": [
|
||||
"npm: protos"
|
||||
],
|
||||
"presentation": {
|
||||
"group": "watch",
|
||||
"reveal": "always"
|
||||
},
|
||||
"options": {
|
||||
"env": {
|
||||
"IS_DEV": "true",
|
||||
"IS_TEST": "true"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "dev:webview",
|
||||
@@ -123,12 +55,10 @@
|
||||
],
|
||||
"isBackground": true,
|
||||
"label": "npm: dev:webview",
|
||||
"dependsOn": [
|
||||
"npm: protos"
|
||||
],
|
||||
"presentation": {
|
||||
"group": "watch",
|
||||
"reveal": "always"
|
||||
"reveal": "never",
|
||||
"close": true
|
||||
},
|
||||
"options": {
|
||||
"env": {
|
||||
@@ -140,77 +70,13 @@
|
||||
"type": "npm",
|
||||
"script": "watch:esbuild",
|
||||
"group": "build",
|
||||
"problemMatcher": {
|
||||
"pattern": [
|
||||
{
|
||||
"regexp": "^✘ \\[ERROR\\] (.*)$",
|
||||
"message": 1
|
||||
},
|
||||
{
|
||||
"regexp": "^\\s+(.*):(\\d+):(\\d+):$",
|
||||
"file": 1,
|
||||
"line": 2,
|
||||
"column": 3
|
||||
}
|
||||
],
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": "^\\[watch\\] build started$",
|
||||
"endsPattern": "^\\[watch\\] build finished$"
|
||||
}
|
||||
},
|
||||
"problemMatcher": "$esbuild-watch",
|
||||
"isBackground": true,
|
||||
"label": "npm: watch:esbuild",
|
||||
"dependsOn": [
|
||||
"npm: protos"
|
||||
],
|
||||
"presentation": {
|
||||
"group": "watch",
|
||||
"reveal": "always"
|
||||
},
|
||||
"options": {
|
||||
"env": {
|
||||
"IS_DEV": "true"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "watch:esbuild:test",
|
||||
"group": "build",
|
||||
"problemMatcher": {
|
||||
"pattern": [
|
||||
{
|
||||
"regexp": "^✘ \\[ERROR\\] (.*)$",
|
||||
"message": 1
|
||||
},
|
||||
{
|
||||
"regexp": "^\\s+(.*):(\\d+):(\\d+):$",
|
||||
"file": 1,
|
||||
"line": 2,
|
||||
"column": 3
|
||||
}
|
||||
],
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": "^\\[watch\\] build started$",
|
||||
"endsPattern": "^\\[watch\\] build finished$"
|
||||
}
|
||||
},
|
||||
"isBackground": true,
|
||||
"label": "npm: watch:esbuild:test",
|
||||
"dependsOn": [
|
||||
"npm: protos"
|
||||
],
|
||||
"presentation": {
|
||||
"group": "watch",
|
||||
"reveal": "always"
|
||||
},
|
||||
"options": {
|
||||
"env": {
|
||||
"IS_DEV": "true",
|
||||
"IS_TEST": "true"
|
||||
}
|
||||
"reveal": "never",
|
||||
"close": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -220,12 +86,10 @@
|
||||
"problemMatcher": "$tsc-watch",
|
||||
"isBackground": true,
|
||||
"label": "npm: watch:tsc",
|
||||
"dependsOn": [
|
||||
"npm: protos"
|
||||
],
|
||||
"presentation": {
|
||||
"group": "watch",
|
||||
"reveal": "always"
|
||||
"reveal": "never",
|
||||
"close": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -233,36 +97,21 @@
|
||||
"script": "watch-tests",
|
||||
"problemMatcher": "$tsc-watch",
|
||||
"isBackground": true,
|
||||
"dependsOn": [
|
||||
"npm: protos"
|
||||
],
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"reveal": "never",
|
||||
"group": "watchers"
|
||||
},
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"label": "tasks: watch-tests",
|
||||
"dependsOn": [
|
||||
"npm: protos",
|
||||
"npm: watch",
|
||||
"npm: watch-tests"
|
||||
],
|
||||
"dependsOn": ["npm: watch", "npm: watch-tests"],
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "stop",
|
||||
"command": "echo ${input:terminate}",
|
||||
"type": "shell"
|
||||
},
|
||||
{
|
||||
"label": "clean-tmp-user",
|
||||
"type": "shell",
|
||||
"dependsOn": [
|
||||
"watch"
|
||||
],
|
||||
"command": "rm -rf ${workspaceFolder}/dist/tmp/user && mkdir -p ${workspaceFolder}/dist/tmp/user"
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
|
||||
+5
-33
@@ -1,40 +1,24 @@
|
||||
# Default
|
||||
.vscode/**
|
||||
.vscode-test/**
|
||||
out/
|
||||
dist-standalone/
|
||||
node_modules/
|
||||
out/**
|
||||
node_modules/**
|
||||
src/**
|
||||
standalone/**
|
||||
.gitignore
|
||||
.yarnrc
|
||||
esbuild.js
|
||||
vsc-extension-quickstart.md
|
||||
tsconfig*.json
|
||||
**/tsconfig.json
|
||||
**/.eslintrc.json
|
||||
**/*.map
|
||||
**/*.ts
|
||||
**/.vscode-test.*
|
||||
eslint-rules/**
|
||||
.github/**
|
||||
.husky/**
|
||||
|
||||
# Custom
|
||||
**/demo.gif
|
||||
demo.gif
|
||||
.nvmrc
|
||||
.gitattributes
|
||||
.prettierignore
|
||||
.husky/
|
||||
.github/
|
||||
eslint-rules/
|
||||
old_docs/
|
||||
evals/
|
||||
.changie.yaml
|
||||
.codespellrc
|
||||
.mocharc.json
|
||||
buf.yaml
|
||||
.changeset/
|
||||
.clinerules/
|
||||
|
||||
# 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/**
|
||||
@@ -48,7 +32,6 @@ webview-ui/node_modules/**
|
||||
|
||||
# Ignore docs
|
||||
docs/**
|
||||
old_docs/**
|
||||
|
||||
# Fix issue where codicons don't get packaged (https://github.com/microsoft/vscode-extension-samples/issues/692)
|
||||
!node_modules/@vscode/codicons/dist/codicon.css
|
||||
@@ -58,15 +41,4 @@ old_docs/**
|
||||
!src/integrations/theme/default-themes/**
|
||||
|
||||
# Include icons
|
||||
!assets/icons/**
|
||||
|
||||
# Ignore E2E build files
|
||||
e2e-build.mjs
|
||||
e2e.vsix
|
||||
test-results/
|
||||
|
||||
# Ignore Storybook files
|
||||
**/*.stories.tsx
|
||||
*storybook.log
|
||||
storybook-static
|
||||
**/StorybookDecorator.tsx
|
||||
!assets/icons/**
|
||||
+180
-1114
File diff suppressed because it is too large
Load Diff
+3
-133
@@ -10,73 +10,16 @@ Bug reports help make Cline better for everyone! Before creating a new issue, pl
|
||||
🔐 <b>Important:</b> If you discover a security vulnerability, please use the <a href="https://github.com/cline/cline/security/advisories/new">Github security tool to report it privately</a>.
|
||||
</blockquote>
|
||||
|
||||
|
||||
## Before Contributing
|
||||
|
||||
All contributions must begin with a GitHub Issue, unless the change is for small bug fixes, typo corrections, minor wording improvements, or simple type fixes that don't change functionality.
|
||||
**For features and contributions**:
|
||||
- First check the [Feature Requests discussions board](https://github.com/cline/cline/discussions/categories/feature-requests) for similar ideas
|
||||
- If your idea is new, create a new feature request
|
||||
- Wait for approval from core maintainers before starting implementation
|
||||
- Once approved, feel free to begin working on a PR with the help of our community!
|
||||
|
||||
**PRs without approved issues may be closed.**
|
||||
|
||||
|
||||
## Deciding What to Work On
|
||||
|
||||
Looking for a good first contribution? Check out issues labeled ["good first issue"](https://github.com/cline/cline/labels/good%20first%20issue) or ["help wanted"](https://github.com/cline/cline/labels/help%20wanted). These are specifically curated for new contributors and areas where we'd love some help!
|
||||
|
||||
We also welcome contributions to our [documentation](https://github.com/cline/cline/tree/main/docs)! Whether it's fixing typos, improving existing guides, or creating new educational content - we'd love to build a community-driven repository of resources that helps everyone get the most out of Cline. You can start by diving into `/docs` and looking for areas that need improvement.
|
||||
|
||||
If you're planning to work on a bigger feature, please create a [feature request](https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop) first so we can discuss whether it aligns with Cline's vision.
|
||||
|
||||
## Development Setup
|
||||
|
||||
|
||||
### Local Development Instructions
|
||||
|
||||
1. Clone the repository _(Requires [git-lfs](https://git-lfs.com/))_:
|
||||
```bash
|
||||
git clone https://github.com/cline/cline.git
|
||||
```
|
||||
2. Open the project in VSCode:
|
||||
```bash
|
||||
code cline
|
||||
```
|
||||
3. Install the necessary dependencies for the extension and webview-gui:
|
||||
```bash
|
||||
npm run install:all
|
||||
```
|
||||
4. Launch by pressing `F5` (or `Run`->`Start Debugging`) to open a new VSCode window with the extension loaded. (You may need to install the [esbuild problem matchers extension](https://marketplace.visualstudio.com/items?itemName=connor4312.esbuild-problem-matchers) if you run into issues building the project.)
|
||||
|
||||
|
||||
|
||||
|
||||
### Creating a Pull Request
|
||||
|
||||
1. Before creating a PR, generate a changeset entry:
|
||||
```bash
|
||||
npm run changeset
|
||||
```
|
||||
This will prompt you for:
|
||||
- Type of change (major, minor, patch)
|
||||
- `major` → breaking changes (1.0.0 → 2.0.0)
|
||||
- `minor` → new features (1.0.0 → 1.1.0)
|
||||
- `patch` → bug fixes (1.0.0 → 1.0.1)
|
||||
- Description of your changes
|
||||
|
||||
2. Commit your changes and the generated `.changeset` file
|
||||
|
||||
3. Push your branch and create a PR on GitHub. Our CI will:
|
||||
- Run tests and checks
|
||||
- Changesetbot will create a comment showing the version impact
|
||||
- When merged to main, changesetbot will create a Version Packages PR
|
||||
- When the Version Packages PR is merged, a new release will be published
|
||||
4. Testing
|
||||
- Run `npm run test` to run tests locally.
|
||||
- Before submitting PR, run `npm run format:fix` to format your code
|
||||
|
||||
### Extension
|
||||
|
||||
1. **VS Code Extensions**
|
||||
|
||||
- When opening the project, VS Code will prompt you to install recommended extensions
|
||||
@@ -86,51 +29,8 @@ We also welcome contributions to our [documentation](https://github.com/cline/cl
|
||||
2. **Local Development**
|
||||
- Run `npm run install:all` to install dependencies
|
||||
- Run `npm run test` to run tests locally
|
||||
- Run → Start Debugging or `>Debug: Select and Start Debugging` and wait for a new VS Code instance to open
|
||||
- Before submitting PR, run `npm run format:fix` to format your code
|
||||
|
||||
3. **Linux-specific Setup**
|
||||
VS Code extension tests on Linux require the following system libraries:
|
||||
|
||||
- `dbus`
|
||||
- `libasound2`
|
||||
- `libatk-bridge2.0-0`
|
||||
- `libatk1.0-0`
|
||||
- `libdrm2`
|
||||
- `libgbm1`
|
||||
- `libgtk-3-0`
|
||||
- `libnss3`
|
||||
- `libx11-xcb1`
|
||||
- `libxcomposite1`
|
||||
- `libxdamage1`
|
||||
- `libxfixes3`
|
||||
- `libxkbfile1`
|
||||
- `libxrandr2`
|
||||
- `xvfb`
|
||||
|
||||
These libraries provide necessary GUI components and system services for the test environment.
|
||||
|
||||
For example, on Debian-based distributions (e.g., Ubuntu), you can install these libraries using apt:
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y \
|
||||
dbus \
|
||||
libasound2 \
|
||||
libatk-bridge2.0-0 \
|
||||
libatk1.0-0 \
|
||||
libdrm2 \
|
||||
libgbm1 \
|
||||
libgtk-3-0 \
|
||||
libnss3 \
|
||||
libx11-xcb1 \
|
||||
libxcomposite1 \
|
||||
libxdamage1 \
|
||||
libxfixes3 \
|
||||
libxkbfile1 \
|
||||
libxrandr2 \
|
||||
xvfb
|
||||
```
|
||||
|
||||
## Writing and Submitting Code
|
||||
|
||||
Anyone can contribute code to Cline, but we ask that you follow these guidelines to ensure your contributions can be smoothly integrated:
|
||||
@@ -146,7 +46,7 @@ Anyone can contribute code to Cline, but we ask that you follow these guidelines
|
||||
- Run `npm run lint` to check code style
|
||||
- Run `npm run format` to automatically format code
|
||||
- All PRs must pass CI checks which include both linting and formatting
|
||||
- Address any warnings or errors from linter before submitting
|
||||
- Address any ESLint warnings or errors before submitting
|
||||
- Follow TypeScript best practices and maintain type safety
|
||||
|
||||
3. **Testing**
|
||||
@@ -156,36 +56,6 @@ Anyone can contribute code to Cline, but we ask that you follow these guidelines
|
||||
- Update existing tests if your changes affect them
|
||||
- Include both unit tests and integration tests where appropriate
|
||||
|
||||
**End-to-End (E2E) Testing**
|
||||
|
||||
Cline includes comprehensive E2E tests using Playwright that simulate real user interactions with the extension in VS Code:
|
||||
|
||||
- **Running E2E tests:**
|
||||
```bash
|
||||
npm run test:e2e # Build and run all E2E tests
|
||||
npm run e2e # Run tests without rebuilding
|
||||
npm run test:e2e -- --debug # Run with interactive debugger
|
||||
```
|
||||
|
||||
- **Writing E2E tests:**
|
||||
- Tests are located in `src/test/e2e/`
|
||||
- Use the `e2e` fixture for single-root workspace tests
|
||||
- Use `e2eMultiRoot` fixture for multi-root workspace tests
|
||||
- Follow existing patterns in `auth.test.ts`, `chat.test.ts`, `diff.test.ts`, and `editor.test.ts`
|
||||
- See `src/test/e2e/README.md` for detailed documentation
|
||||
|
||||
- **Debug mode features:**
|
||||
- Interactive Playwright Inspector for step-by-step debugging
|
||||
- Record new interactions and generate test code automatically
|
||||
- Visual VS Code instance for manual testing
|
||||
- Element inspection and selector validation
|
||||
|
||||
- **Test environment:**
|
||||
- Automated VS Code setup with Cline extension loaded
|
||||
- Mock API server for backend testing
|
||||
- Temporary workspaces with test fixtures
|
||||
- Video recording for failed tests
|
||||
|
||||
4. **Version Management with Changesets**
|
||||
|
||||
- Create a changeset for any user-facing changes using `npm run changeset`
|
||||
|
||||
@@ -1,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
|
||||
@@ -24,7 +24,7 @@ English | <a href="https://github.com/cline/cline/blob/main/locales/es/README.md
|
||||
<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>Feature Requests</strong></a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://docs.cline.bot/getting-started/for-new-coders" target="_blank"><strong>Getting Started</strong></a>
|
||||
<a href="https://docs.cline.bot/getting-started/getting-started-new-coders" target="_blank"><strong>Getting Started</strong></a>
|
||||
</td>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -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 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.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.
|
||||
|
||||
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.
|
||||
@@ -51,7 +51,7 @@ Thanks to [Claude Sonnet's agentic coding capabilities](https://www.anthropic.c
|
||||
|
||||
### Use any API and Model
|
||||
|
||||
Cline supports API providers like OpenRouter, Anthropic, OpenAI, Google Gemini, AWS Bedrock, Azure, GCP Vertex, Cerebras and Groq. You can also configure any OpenAI compatible API, or use a local model through LM Studio/Ollama. If you're using OpenRouter, the extension fetches their latest model list, allowing you to use the newest models as soon as they're available.
|
||||
Cline supports API providers like OpenRouter, Anthropic, OpenAI, Google Gemini, AWS Bedrock, Azure, and GCP Vertex. You can also configure any OpenAI compatible API, or use a local model through LM Studio/Ollama. If you're using OpenRouter, the extension fetches their latest model list, allowing you to use the newest models as soon as they're available.
|
||||
|
||||
The extension also keeps track of total tokens and API usage cost for the entire task loop and individual requests, keeping you informed of spend every step of the way.
|
||||
|
||||
@@ -87,7 +87,7 @@ All changes made by Cline are recorded in your file's Timeline, providing an eas
|
||||
|
||||
### Use the Browser
|
||||
|
||||
With Claude Sonnet's new [Computer Use](https://www.anthropic.com/news/3-5-models-and-computer-use) capability, Cline can launch a browser, click elements, type text, and scroll, capturing screenshots and console logs at each step. This allows for interactive debugging, end-to-end testing, and even general web use! This gives him autonomy to fixing visual bugs and runtime issues without you needing to handhold and copy-pasting error logs yourself.
|
||||
With Claude 3.5 Sonnet's new [Computer Use](https://www.anthropic.com/news/3-5-models-and-computer-use) capability, Cline can launch a browser, click elements, type text, and scroll, capturing screenshots and console logs at each step. This allows for interactive debugging, end-to-end testing, and even general web use! This gives him autonomy to fixing visual bugs and runtime issues without you needing to handhold and copy-pasting error logs yourself.
|
||||
|
||||
Try asking Cline to "test the app", and watch as he runs a command like `npm run dev`, launches your locally running dev server in a browser, and performs a series of tests to confirm that everything works. [See a demo here.](https://x.com/sdrzn/status/1850880547825823989)
|
||||
|
||||
@@ -141,6 +141,50 @@ For example, when working with a local web server, you can use 'Restore Workspac
|
||||
|
||||
To contribute to the project, start with our [Contributing Guide](CONTRIBUTING.md) to learn the basics. You can also join our [Discord](https://discord.gg/cline) to chat with other contributors in the `#contributors` channel. If you're looking for full-time work, check out our open positions on our [careers page](https://cline.bot/join-us)!
|
||||
|
||||
<details>
|
||||
<summary>Local Development Instructions</summary>
|
||||
|
||||
1. Clone the repository _(Requires [git-lfs](https://git-lfs.com/))_:
|
||||
```bash
|
||||
git clone https://github.com/cline/cline.git
|
||||
```
|
||||
2. Open the project in VSCode:
|
||||
```bash
|
||||
code cline
|
||||
```
|
||||
3. Install the necessary dependencies for the extension and webview-gui:
|
||||
```bash
|
||||
npm run install:all
|
||||
```
|
||||
4. Launch by pressing `F5` (or `Run`->`Start Debugging`) to open a new VSCode window with the extension loaded. (You may need to install the [esbuild problem matchers extension](https://marketplace.visualstudio.com/items?itemName=connor4312.esbuild-problem-matchers) if you run into issues building the project.)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Creating a Pull Request</summary>
|
||||
|
||||
1. Before creating a PR, generate a changeset entry:
|
||||
```bash
|
||||
npm run changeset
|
||||
```
|
||||
This will prompt you for:
|
||||
- Type of change (major, minor, patch)
|
||||
- `major` → breaking changes (1.0.0 → 2.0.0)
|
||||
- `minor` → new features (1.0.0 → 1.1.0)
|
||||
- `patch` → bug fixes (1.0.0 → 1.0.1)
|
||||
- Description of your changes
|
||||
|
||||
2. Commit your changes and the generated `.changeset` file
|
||||
|
||||
3. Push your branch and create a PR on GitHub. Our CI will:
|
||||
- Run tests and checks
|
||||
- Changesetbot will create a comment showing the version impact
|
||||
- When merged to main, changesetbot will create a Version Packages PR
|
||||
- When the Version Packages PR is merged, a new release will be published
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
## License
|
||||
|
||||
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
|
||||
|
||||
-166
@@ -1,166 +0,0 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.1.4/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true,
|
||||
"defaultBranch": "main"
|
||||
},
|
||||
"assist": {
|
||||
"enabled": true,
|
||||
"actions": {
|
||||
"source": {
|
||||
"organizeImports": "on",
|
||||
"useSortedAttributes": "on"
|
||||
}
|
||||
}
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"domains": {
|
||||
"react": "recommended"
|
||||
},
|
||||
// Ideally we would want to turn on all the rules that are currently off,
|
||||
// keeping them off currently to make sure only changes on the migrations
|
||||
// are included in the initial PR before we apply the format and lint changes.
|
||||
// TODO: turn on all rules that are currently off if applicable.
|
||||
// TODO: Remove --diagnostic-level=error from CI commands.
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"correctness": {
|
||||
"useExhaustiveDependencies": "off",
|
||||
"noUndeclaredVariables": "off",
|
||||
"noEmptyPattern": "off",
|
||||
"useJsxKeyInIterable": "off",
|
||||
"noInnerDeclarations": "off",
|
||||
"useHookAtTopLevel": "off",
|
||||
"useYield": "off",
|
||||
"noConstructorReturn": "off",
|
||||
"noInvalidPositionAtImportRule": "off",
|
||||
"noSwitchDeclarations": "off",
|
||||
"noUnusedImports": "error"
|
||||
},
|
||||
"a11y": "off",
|
||||
"style": {
|
||||
"useNodejsImportProtocol": "off",
|
||||
"useImportType": "off",
|
||||
"useBlockStatements": "warn",
|
||||
"useNamingConvention": "off",
|
||||
"useThrowOnlyError": "info",
|
||||
"useConsistentArrayType": "off",
|
||||
"noParameterAssign": "off",
|
||||
"useAsConstAssertion": "off",
|
||||
"useDefaultParameterLast": "off",
|
||||
"noNonNullAssertion": "off",
|
||||
"useEnumInitializers": "off",
|
||||
"useSelfClosingElements": "off",
|
||||
"useSingleVarDeclarator": "off",
|
||||
"useNumberNamespace": "off",
|
||||
"noInferrableTypes": "off",
|
||||
"useTemplate": "off",
|
||||
"noUselessElse": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noDoubleEquals": "warn",
|
||||
"noImplicitAnyLet": "info",
|
||||
"noThenProperty": "off",
|
||||
"noAsyncPromiseExecutor": "off",
|
||||
"noImportAssign": "off",
|
||||
"noExplicitAny": "off",
|
||||
"noControlCharactersInRegex": "off",
|
||||
"noShadowRestrictedNames": "off",
|
||||
"noArrayIndexKey": "info",
|
||||
"noAssignInExpressions": "warn"
|
||||
},
|
||||
"complexity": {
|
||||
"noUselessConstructor": "off",
|
||||
"useOptionalChain": "off",
|
||||
"noBannedTypes": "off",
|
||||
"useLiteralKeys": "off",
|
||||
"noUselessCatch": "off",
|
||||
"noUselessSwitchCase": "off",
|
||||
"noStaticOnlyClass": "off"
|
||||
},
|
||||
"security": {
|
||||
"noDangerouslySetInnerHtml": "warn"
|
||||
}
|
||||
}
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "tab",
|
||||
"indentWidth": 4,
|
||||
"lineWidth": 130,
|
||||
"lineEnding": "lf",
|
||||
"formatWithErrors": true
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"semicolons": "asNeeded",
|
||||
"arrowParentheses": "always",
|
||||
"bracketSameLine": true,
|
||||
"bracketSpacing": true,
|
||||
"jsxQuoteStyle": "double",
|
||||
"quoteProperties": "asNeeded",
|
||||
"trailingCommas": "all"
|
||||
}
|
||||
},
|
||||
"json": {
|
||||
"formatter": {
|
||||
"trailingCommas": "none",
|
||||
"expand": "always"
|
||||
}
|
||||
},
|
||||
"files": {
|
||||
"includes": [
|
||||
"**",
|
||||
"!**/dist/**",
|
||||
"!**/dist-*/**",
|
||||
"!**/out/**",
|
||||
"!**/evals/**",
|
||||
"!**/playwright/**",
|
||||
"!**/test-results/**",
|
||||
"!**/node_modules/**",
|
||||
"!**/webview-ui/build/**",
|
||||
"!**/generated/**",
|
||||
"!**/proto/**",
|
||||
"!**/tests/specs/**"
|
||||
]
|
||||
},
|
||||
"plugins": [
|
||||
"src/dev/grit/process-env.grit"
|
||||
],
|
||||
"overrides": [
|
||||
{
|
||||
"includes": [
|
||||
"**",
|
||||
"!**/hosts/vscode/**",
|
||||
"!**/test/**",
|
||||
"!**/*.test.ts",
|
||||
"!src/dev/**",
|
||||
"!src/extension.ts",
|
||||
"!src/integrations/git/commit-message-generator.ts",
|
||||
"!src/integrations/terminal/**",
|
||||
"!src/core/controller/ui/openWalkthrough.ts"
|
||||
],
|
||||
"plugins": [
|
||||
"src/dev/grit/vscode-api.grit"
|
||||
]
|
||||
},
|
||||
{
|
||||
"includes": [
|
||||
"**",
|
||||
"!src/core/storage/state-migrations.ts",
|
||||
"!src/core/storage/FileContextTracker.ts",
|
||||
"!src/core/context/context-tracking/FileContextTracker.ts",
|
||||
"!src/common.ts",
|
||||
"!src/services/logging/distinctId.ts",
|
||||
"!src/core/storage/utils/state-helpers.ts",
|
||||
"!src/extension.ts"
|
||||
],
|
||||
"plugins": [
|
||||
"src/dev/grit/use-cache-service.grit"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
version: v2
|
||||
modules:
|
||||
- path: proto
|
||||
name: cline/cline/lint
|
||||
|
||||
lint:
|
||||
use:
|
||||
- STANDARD
|
||||
|
||||
except: # Add exceptions for current patterns that contradict STANDARD settings
|
||||
- RPC_PASCAL_CASE # rpcs are camel case (start with lowercase)
|
||||
- RPC_REQUEST_RESPONSE_UNIQUE # request messages are not unique.
|
||||
- RPC_REQUEST_STANDARD_NAME # request messages dont all end with Request
|
||||
- RPC_RESPONSE_STANDARD_NAME # response messages dont all end with Response
|
||||
- PACKAGE_VERSION_SUFFIX # package name does not contain version.
|
||||
- ENUM_VALUE_PREFIX # enum values dont start with the enum name.
|
||||
- ENUM_ZERO_VALUE_SUFFIX # first value does not have to be UNSPECIFIED.
|
||||
|
||||
# breaking:
|
||||
# use:
|
||||
# - WIRE_JSON # Detect changes that break the json wire format (this is the minimum recommended level.)
|
||||
@@ -1,2 +0,0 @@
|
||||
cline-core-debug.log
|
||||
bin/*
|
||||
@@ -1,6 +0,0 @@
|
||||
/_____/\ /_/\ /_______/\/__/\ /__/\ /_____/\
|
||||
\:::__\/ \:\ \ \__.::._\/\::\_\\ \ \\::::_\/_
|
||||
\:\ \ __\:\ \ \::\ \ \:. `-\ \ \\:\/___/\
|
||||
\:\ \/_/\\:\ \____ _\::\ \__\:. _ \ \\::___\/_
|
||||
\:\_\ \ \\:\/___/\/__\::\__/\\. \`-\ \ \\:\____/\
|
||||
\_____\/ \_____\/\________\/ \__\/ \__\/ \_____\/
|
||||
@@ -1,71 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/cline/cli/pkg/hostbridge"
|
||||
)
|
||||
|
||||
var (
|
||||
port int
|
||||
verbose bool
|
||||
)
|
||||
|
||||
func main() {
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "cline-host",
|
||||
Short: "Cline Host Bridge Service",
|
||||
Long: `A simple host bridge service that provides host operations for Cline Core.`,
|
||||
RunE: runServer,
|
||||
}
|
||||
|
||||
rootCmd.Flags().IntVarP(&port, "port", "p", 51052, "port to listen on")
|
||||
rootCmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "verbose logging")
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func runServer(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
// Create gRPC hostbridge service
|
||||
service := hostbridge.NewGrpcServer(port, verbose)
|
||||
|
||||
// Handle graceful shutdown
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
go func() {
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sigChan
|
||||
|
||||
if verbose {
|
||||
log.Println("Shutting down hostbridge server...")
|
||||
}
|
||||
|
||||
cancel()
|
||||
}()
|
||||
|
||||
// Start server
|
||||
if verbose {
|
||||
log.Printf("Starting Cline Host Bridge on port %d", port)
|
||||
}
|
||||
|
||||
// Run the service
|
||||
if err := service.Start(ctx); err != nil {
|
||||
return fmt.Errorf("failed to run service: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/cline/cli/pkg/cli"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
coreAddress string
|
||||
cfgFile string
|
||||
verbose bool
|
||||
outputFormat string
|
||||
)
|
||||
|
||||
func main() {
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "cline",
|
||||
Short: "Cline CLI - AI-powered coding assistant",
|
||||
Long: `A command-line interface for interacting with Cline AI coding assistant.
|
||||
|
||||
This CLI provides access to Cline's task management, configuration, and
|
||||
monitoring capabilities from the terminal.`,
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
if outputFormat != "rich" && outputFormat != "json" && outputFormat != "plain" {
|
||||
return fmt.Errorf("invalid output format '%s': must be one of 'rich', 'json', or 'plain'", outputFormat)
|
||||
}
|
||||
|
||||
return global.InitializeGlobalConfig(&global.GlobalConfig{
|
||||
ConfigPath: cfgFile,
|
||||
Verbose: verbose,
|
||||
OutputFormat: outputFormat,
|
||||
CoreAddress: coreAddress,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
rootCmd.PersistentFlags().StringVar(&coreAddress, "address", fmt.Sprintf("localhost:%d", common.DEFAULT_CLINE_CORE_PORT), "Cline Core gRPC address")
|
||||
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.cline/config.yaml)")
|
||||
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output")
|
||||
rootCmd.PersistentFlags().StringVarP(&outputFormat, "output-format", "o", "rich", "output format (rich|json|plain)")
|
||||
|
||||
rootCmd.AddCommand(cli.NewTaskCommand())
|
||||
rootCmd.AddCommand(cli.NewInstanceCommand())
|
||||
rootCmd.AddCommand(cli.NewVersionCommand())
|
||||
rootCmd.AddCommand(cli.NewAuthCommand())
|
||||
|
||||
if err := rootCmd.ExecuteContext(context.Background()); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/cline/cli/pkg/common"
|
||||
)
|
||||
|
||||
// 2. Multi-instance start: default_instance remains the first started.
|
||||
func TestMultiInstanceDefaultUnchanged(t *testing.T) {
|
||||
_ = setTempClineDir(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Start first instance and wait healthy
|
||||
_ = mustRunCLI(ctx, t, "instance", "new")
|
||||
out1 := listInstancesJSON(ctx, t)
|
||||
if len(out1.CoreInstances) != 1 {
|
||||
t.Fatalf("expected 1 instance, got %d", len(out1.CoreInstances))
|
||||
}
|
||||
firstAddr := out1.CoreInstances[0].Address
|
||||
waitForAddressHealthy(t, firstAddr, defaultTimeout)
|
||||
|
||||
// Start second instance
|
||||
_ = mustRunCLI(ctx, t, "instance", "new")
|
||||
out2 := listInstancesJSON(ctx, t)
|
||||
if len(out2.CoreInstances) < 2 {
|
||||
t.Fatalf("expected at least 2 instances, got %d", len(out2.CoreInstances))
|
||||
}
|
||||
|
||||
// Default should remain the first started address
|
||||
if out2.DefaultInstance != firstAddr {
|
||||
t.Fatalf("default changed; expected %s, got %s", firstAddr, out2.DefaultInstance)
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Default.json update after removal of current default
|
||||
func TestDefaultJsonUpdateAfterRemoval(t *testing.T) {
|
||||
_ = setTempClineDir(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Start two instances
|
||||
_ = mustRunCLI(ctx, t, "instance", "new")
|
||||
_ = mustRunCLI(ctx, t, "instance", "new")
|
||||
|
||||
out := listInstancesJSON(ctx, t)
|
||||
if len(out.CoreInstances) < 2 {
|
||||
t.Fatalf("expected at least 2 instances, got %d", len(out.CoreInstances))
|
||||
}
|
||||
|
||||
// Choose second as new default
|
||||
target := out.CoreInstances[1]
|
||||
waitForAddressHealthy(t, target.Address, defaultTimeout)
|
||||
|
||||
// Set as default
|
||||
_ = mustRunCLI(ctx, t, "instance", "use", target.Address)
|
||||
|
||||
// Verify default switched
|
||||
out = listInstancesJSON(ctx, t)
|
||||
if out.DefaultInstance != target.Address {
|
||||
t.Fatalf("default_instance not updated to %s (got %s)", target.Address, out.DefaultInstance)
|
||||
}
|
||||
|
||||
// Kill the default instance using runtime PID discovery
|
||||
corePID := getCorePID(t, target.Address)
|
||||
if corePID <= 0 {
|
||||
t.Fatalf("could not find PID for core process at %s", target.Address)
|
||||
}
|
||||
t.Logf("Killing cline-core process PID %d for instance %s", corePID, target.Address)
|
||||
if err := syscall.Kill(corePID, syscall.SIGKILL); err != nil {
|
||||
t.Fatalf("kill pid %d: %v", corePID, err)
|
||||
}
|
||||
|
||||
// Wait for removal
|
||||
waitForAddressRemoved(t, target.Address, longTimeout)
|
||||
|
||||
// Clean up dangling host process (SIGKILL leaves these behind by design)
|
||||
t.Logf("Cleaning up dangling host process on port %d", target.HostPort())
|
||||
findAndKillHostProcess(t, target.HostPort())
|
||||
|
||||
// Ensure default_instance updated to another available instance (or removed if none remain)
|
||||
out = listInstancesJSON(ctx, t)
|
||||
|
||||
// If there are instances left, default_instance must be one of them
|
||||
if len(out.CoreInstances) > 0 {
|
||||
found := false
|
||||
for _, it := range out.CoreInstances {
|
||||
if out.DefaultInstance == it.Address {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("default_instance %s not set to an existing instance after removal", out.DefaultInstance)
|
||||
}
|
||||
} else {
|
||||
// No instances remain; cli-default-instance.json should be removed
|
||||
clineDir := getClineDir(t)
|
||||
defPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "settings", "cli-default-instance.json")
|
||||
if _, err := os.Stat(defPath); err == nil {
|
||||
t.Fatalf("expected cli-default-instance.json removed when no instances remain")
|
||||
}
|
||||
}
|
||||
|
||||
// Also verify cli-default-instance.json on disk reflects the in-memory default (if any)
|
||||
clineDir := getClineDir(t)
|
||||
defPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "settings", "cli-default-instance.json")
|
||||
if len(out.CoreInstances) > 0 {
|
||||
raw, err := os.ReadFile(defPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read cli-default-instance.json: %v", err)
|
||||
}
|
||||
var tmp struct {
|
||||
DefaultInstance string `json:"default_instance"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &tmp); err != nil {
|
||||
t.Fatalf("unmarshal cli-default-instance.json: %v", err)
|
||||
}
|
||||
if tmp.DefaultInstance != out.DefaultInstance {
|
||||
t.Fatalf("cli-default-instance.json mismatch: file=%s list=%s", tmp.DefaultInstance, out.DefaultInstance)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 11. SQLite database missing (edge): list succeeds and returns empty set
|
||||
func TestRegistryDirMissingEdge(t *testing.T) {
|
||||
clineDir := setTempClineDir(t)
|
||||
|
||||
// Remove the settings directory entirely (which contains locks.db)
|
||||
settingsDir := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER)
|
||||
if err := os.RemoveAll(settingsDir); err != nil {
|
||||
t.Fatalf("RemoveAll(%s): %v", common.SETTINGS_SUBFOLDER, err)
|
||||
}
|
||||
|
||||
// Listing should succeed and return empty results
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
|
||||
defer cancel()
|
||||
out := listInstancesJSON(ctx, t)
|
||||
if len(out.CoreInstances) != 0 {
|
||||
t.Fatalf("expected 0 instances after removing %s dir, got %d", common.SETTINGS_SUBFOLDER, len(out.CoreInstances))
|
||||
}
|
||||
|
||||
// Ensure cli-default-instance.json not present
|
||||
defPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "settings", "cli-default-instance.json")
|
||||
if _, err := os.Stat(defPath); err == nil {
|
||||
t.Fatalf("expected no cli-default-instance.json after removing %s dir", common.SETTINGS_SUBFOLDER)
|
||||
}
|
||||
}
|
||||
@@ -1,378 +0,0 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/common"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultTimeout = 30 * time.Second
|
||||
longTimeout = 60 * time.Second
|
||||
pollInterval = 250 * time.Millisecond
|
||||
instancesBinRel = "../bin/cline"
|
||||
)
|
||||
|
||||
func repoAwareBinPath(t *testing.T) string {
|
||||
// Tests live in repoRoot/cli/e2e. Binary is at repoRoot/cli/bin/cline
|
||||
t.Helper()
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("Getwd error: %v", err)
|
||||
}
|
||||
// cli/e2e -> cli/bin/cline
|
||||
p := filepath.Clean(filepath.Join(wd, instancesBinRel))
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
t.Fatalf("CLI binary not found at %s; run `npm run compile-cli` first: %v", p, err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func setTempClineDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
clineDir := filepath.Join(dir, ".cline")
|
||||
if err := os.MkdirAll(clineDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir clineDir: %v", err)
|
||||
}
|
||||
t.Setenv("CLINE_DIR", clineDir)
|
||||
return clineDir
|
||||
}
|
||||
|
||||
func runCLI(ctx context.Context, t *testing.T, args ...string) (string, string, int) {
|
||||
t.Helper()
|
||||
bin := repoAwareBinPath(t)
|
||||
|
||||
// Ensure CLI uses the same CLINE_DIR as the tests by passing --config=<CLINE_DIR>
|
||||
// (InitializeGlobalConfig uses ConfigPath as the base directory for registry.)
|
||||
if clineDir := os.Getenv("CLINE_DIR"); clineDir != "" && !contains(args, "--config") {
|
||||
// Prepend persistent flag so Cobra sees it regardless of subcommand position
|
||||
args = append([]string{"--config", clineDir}, args...)
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, bin, args...)
|
||||
// Run CLI from repo root so relative paths inside CLI (./cli/bin/...) resolve
|
||||
if wd, err := os.Getwd(); err == nil {
|
||||
repoRoot := filepath.Clean(filepath.Join(wd, "..", ".."))
|
||||
cmd.Dir = repoRoot
|
||||
}
|
||||
// propagate env including CLINE_DIR
|
||||
cmd.Env = os.Environ()
|
||||
outB, errB := &strings.Builder{}, &strings.Builder{}
|
||||
cmd.Stdout = outB
|
||||
cmd.Stderr = errB
|
||||
err := cmd.Run()
|
||||
exit := 0
|
||||
if err != nil {
|
||||
// Extract exit code if possible
|
||||
if ee, ok := err.(*exec.ExitError); ok {
|
||||
exit = ee.ExitCode()
|
||||
} else {
|
||||
exit = -1
|
||||
}
|
||||
}
|
||||
return outB.String(), errB.String(), exit
|
||||
}
|
||||
|
||||
func mustRunCLI(ctx context.Context, t *testing.T, args ...string) string {
|
||||
t.Helper()
|
||||
out, errOut, exit := runCLI(ctx, t, args...)
|
||||
if exit != 0 {
|
||||
t.Fatalf("cline %v failed (exit=%d)\nstdout:\n%s\nstderr:\n%s", args, exit, out, errOut)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func listInstancesJSON(ctx context.Context, t *testing.T) common.InstancesOutput {
|
||||
t.Helper()
|
||||
// Trigger CLI to perform cleanup/health by invoking list (table output is ignored)
|
||||
_ = mustRunCLI(ctx, t, "instance", "list")
|
||||
|
||||
// Read from SQLite locks database to build structured output
|
||||
clineDir := getClineDir(t)
|
||||
|
||||
// Load default instance from settings file
|
||||
defaultInstance := readDefaultInstanceFromSettings(t, clineDir)
|
||||
|
||||
// Load instances from SQLite
|
||||
instances := readInstancesFromSQLite(t, clineDir)
|
||||
|
||||
return common.InstancesOutput{
|
||||
DefaultInstance: defaultInstance,
|
||||
CoreInstances: instances,
|
||||
}
|
||||
}
|
||||
|
||||
func hasAddress(in common.InstancesOutput, addr string) bool {
|
||||
for _, it := range in.CoreInstances {
|
||||
if it.Address == addr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func getByAddress(in common.InstancesOutput, addr string) (common.CoreInstanceInfo, bool) {
|
||||
for _, it := range in.CoreInstances {
|
||||
if it.Address == addr {
|
||||
return it, true
|
||||
}
|
||||
}
|
||||
return common.CoreInstanceInfo{}, false
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, timeout time.Duration, cond func() (bool, string)) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
ok, msg := cond()
|
||||
if ok {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("waitFor timeout: %s", msg)
|
||||
}
|
||||
time.Sleep(pollInterval)
|
||||
}
|
||||
}
|
||||
|
||||
func waitForAddressHealthy(t *testing.T, addr string, timeout time.Duration) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
t.Logf("Waiting for gRPC health check on %s...", addr)
|
||||
|
||||
waitFor(t, timeout, func() (bool, string) {
|
||||
if common.IsInstanceHealthy(ctx, addr) {
|
||||
return true, ""
|
||||
}
|
||||
return false, fmt.Sprintf("gRPC health check failed for %s", addr)
|
||||
})
|
||||
|
||||
t.Logf("gRPC health check passed for %s", addr)
|
||||
}
|
||||
|
||||
func waitForAddressRemoved(t *testing.T, addr string, timeout time.Duration) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
waitFor(t, timeout, func() (bool, string) {
|
||||
out := listInstancesJSON(ctx, t)
|
||||
if hasAddress(out, addr) {
|
||||
return false, fmt.Sprintf("address %s still present", addr)
|
||||
}
|
||||
return true, ""
|
||||
})
|
||||
}
|
||||
|
||||
func findFreePort(t *testing.T) int {
|
||||
t.Helper()
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen 127.0.0.1:0: %v", err)
|
||||
}
|
||||
defer l.Close()
|
||||
_, portStr, _ := net.SplitHostPort(l.Addr().String())
|
||||
var port int
|
||||
fmt.Sscanf(portStr, "%d", &port)
|
||||
return port
|
||||
}
|
||||
|
||||
func getClineDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
clineDir := os.Getenv("CLINE_DIR")
|
||||
if clineDir == "" {
|
||||
t.Fatalf("CLINE_DIR not set")
|
||||
}
|
||||
return clineDir
|
||||
}
|
||||
|
||||
// isPortInUse checks if a port is currently in use by any process
|
||||
func isPortInUse(port int) bool {
|
||||
conn, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
|
||||
if err != nil {
|
||||
return true // Port is in use
|
||||
}
|
||||
conn.Close()
|
||||
return false // Port is free
|
||||
}
|
||||
|
||||
// waitForPortClosed waits for a port to become free (no process listening)
|
||||
func waitForPortClosed(t *testing.T, port int, timeout time.Duration) {
|
||||
t.Helper()
|
||||
waitFor(t, timeout, func() (bool, string) {
|
||||
if isPortInUse(port) {
|
||||
return false, fmt.Sprintf("port %d still in use", port)
|
||||
}
|
||||
return true, ""
|
||||
})
|
||||
}
|
||||
|
||||
// waitForPortsClosed waits for both core and host ports to become free
|
||||
func waitForPortsClosed(t *testing.T, corePort, hostPort int, timeout time.Duration) {
|
||||
t.Helper()
|
||||
waitFor(t, timeout, func() (bool, string) {
|
||||
if isPortInUse(corePort) {
|
||||
return false, fmt.Sprintf("core port %d still in use", corePort)
|
||||
}
|
||||
if isPortInUse(hostPort) {
|
||||
return false, fmt.Sprintf("host port %d still in use", hostPort)
|
||||
}
|
||||
return true, ""
|
||||
})
|
||||
}
|
||||
|
||||
// findAndKillHostProcess finds and kills any process listening on the host port
|
||||
// This is used to clean up dangling host processes after SIGKILL tests
|
||||
func findAndKillHostProcess(t *testing.T, hostPort int) {
|
||||
t.Helper()
|
||||
// Use lsof to find process listening on the host port
|
||||
cmd := exec.Command("lsof", "-ti", fmt.Sprintf(":%d", hostPort))
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
// No process found on port - that's fine
|
||||
return
|
||||
}
|
||||
|
||||
pidStr := strings.TrimSpace(string(output))
|
||||
if pidStr == "" {
|
||||
return
|
||||
}
|
||||
|
||||
var pid int
|
||||
if _, err := fmt.Sscanf(pidStr, "%d", &pid); err != nil {
|
||||
t.Logf("Warning: could not parse PID from lsof output: %s", pidStr)
|
||||
return
|
||||
}
|
||||
|
||||
if pid > 0 {
|
||||
t.Logf("Cleaning up dangling host process PID %d on port %d", pid, hostPort)
|
||||
if err := syscall.Kill(pid, syscall.SIGKILL); err != nil {
|
||||
t.Logf("Warning: failed to kill dangling host process %d: %v", pid, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getPIDByPort returns the PID of the process listening on the specified port (fallback method)
|
||||
func getPIDByPort(t *testing.T, port int) int {
|
||||
t.Helper()
|
||||
cmd := exec.Command("lsof", "-ti", fmt.Sprintf(":%d", port))
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return 0 // Process not found
|
||||
}
|
||||
|
||||
pidStr := strings.TrimSpace(string(output))
|
||||
if pidStr == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
pid, err := strconv.Atoi(pidStr)
|
||||
if err != nil {
|
||||
t.Logf("Warning: could not parse PID from lsof output: %s", pidStr)
|
||||
return 0
|
||||
}
|
||||
|
||||
return pid
|
||||
}
|
||||
|
||||
// getCorePIDViaRPC returns the PID of the cline-core process using RPC (preferred method)
|
||||
func getCorePIDViaRPC(t *testing.T, address string) int {
|
||||
t.Helper()
|
||||
|
||||
// Initialize global config to access registry
|
||||
clineDir := os.Getenv("CLINE_DIR")
|
||||
if clineDir == "" {
|
||||
t.Logf("Warning: CLINE_DIR not set, falling back to lsof")
|
||||
return getCorePIDViaLsof(t, address)
|
||||
}
|
||||
|
||||
cfg := &global.GlobalConfig{
|
||||
ConfigPath: clineDir,
|
||||
}
|
||||
|
||||
if err := global.InitializeGlobalConfig(cfg); err != nil {
|
||||
t.Logf("Warning: failed to initialize global config, falling back to lsof: %v", err)
|
||||
return getCorePIDViaLsof(t, address)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Get client for the address
|
||||
client, err := global.Clients.GetRegistry().GetClient(ctx, address)
|
||||
if err != nil {
|
||||
t.Logf("Warning: failed to get client for %s, falling back to lsof: %v", address, err)
|
||||
return getCorePIDViaLsof(t, address)
|
||||
}
|
||||
|
||||
// Call GetProcessInfo RPC
|
||||
processInfo, err := client.State.GetProcessInfo(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
t.Logf("Warning: GetProcessInfo RPC failed for %s, falling back to lsof: %v", address, err)
|
||||
return getCorePIDViaLsof(t, address)
|
||||
}
|
||||
|
||||
return int(processInfo.ProcessId)
|
||||
}
|
||||
|
||||
// getCorePIDViaLsof returns the PID using lsof (fallback method)
|
||||
func getCorePIDViaLsof(t *testing.T, address string) int {
|
||||
t.Helper()
|
||||
_, portStr, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
t.Logf("Warning: invalid address format %s", address)
|
||||
return 0
|
||||
}
|
||||
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
t.Logf("Warning: invalid port in address %s", address)
|
||||
return 0
|
||||
}
|
||||
|
||||
return getPIDByPort(t, port)
|
||||
}
|
||||
|
||||
// getCorePID returns the PID of the cline-core process for the given address
|
||||
// Uses RPC first, falls back to lsof if RPC fails
|
||||
func getCorePID(t *testing.T, address string) int {
|
||||
t.Helper()
|
||||
|
||||
// Try RPC first (preferred method)
|
||||
if pid := getCorePIDViaRPC(t, address); pid > 0 {
|
||||
return pid
|
||||
}
|
||||
|
||||
// Fall back to lsof if RPC fails
|
||||
return getCorePIDViaLsof(t, address)
|
||||
}
|
||||
|
||||
// getHostPID returns the PID of the cline-host process for the given host port
|
||||
func getHostPID(t *testing.T, hostPort int) int {
|
||||
t.Helper()
|
||||
return getPIDByPort(t, hostPort)
|
||||
}
|
||||
|
||||
// contains reports whether slice has the target string.
|
||||
func contains(slice []string, target string) bool {
|
||||
for _, s := range slice {
|
||||
if s == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestMain validates required artifacts exist before running E2E tests.
|
||||
// It does NOT build artifacts. Build manually via:
|
||||
//
|
||||
// npm run compile-standalone
|
||||
// npm run compile-cli
|
||||
func TestMain(m *testing.M) {
|
||||
// Determine repo root from cli/e2e
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "getwd: %v\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
repoRoot := filepath.Clean(filepath.Join(wd, "..", ".."))
|
||||
|
||||
cliBin := filepath.Join(repoRoot, "cli", "bin", "cline")
|
||||
coreJS := filepath.Join(repoRoot, "dist-standalone", "cline-core.js")
|
||||
|
||||
missing := []string{}
|
||||
if _, err := os.Stat(cliBin); err != nil {
|
||||
missing = append(missing, cliBin)
|
||||
}
|
||||
if _, err := os.Stat(coreJS); err != nil {
|
||||
missing = append(missing, coreJS)
|
||||
}
|
||||
|
||||
if len(missing) > 0 {
|
||||
if testing.Short() {
|
||||
// Optional quality-of-life: allow skipping with -short when artifacts are absent
|
||||
fmt.Fprintf(os.Stderr, "[e2e] skipping (-short) due to missing artifacts:\n %s\n", strings.Join(missing, "\n "))
|
||||
os.Exit(0)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "Missing required build artifacts for E2E tests:\n %s\n\nPlease build them first:\n npm run compile-standalone\n npm run compile-cli\n", strings.Join(missing, "\n "))
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/cline/cli/pkg/common"
|
||||
)
|
||||
|
||||
// 9. Mixed localhost vs 127.0.0.1 addresses coexist and are both healthy
|
||||
func TestMixedLocalhostVs127Coexist(t *testing.T) {
|
||||
clineDir := setTempClineDir(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Start one instance
|
||||
_ = mustRunCLI(ctx, t, "instance", "new")
|
||||
|
||||
// Get the running instance and its port/PID
|
||||
out := listInstancesJSON(ctx, t)
|
||||
if len(out.CoreInstances) == 0 {
|
||||
t.Fatalf("expected at least 1 instance")
|
||||
}
|
||||
inst := out.CoreInstances[0]
|
||||
waitForAddressHealthy(t, inst.Address, defaultTimeout)
|
||||
|
||||
// Manually add a SQLite entry for the same port but 127.0.0.1 host
|
||||
addr127 := fmt.Sprintf("127.0.0.1:%d", inst.CorePort())
|
||||
dbPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "locks.db")
|
||||
|
||||
if err := insertRemoteInstanceIntoSQLite(t, dbPath, addr127, inst.CorePort(), inst.HostPort()); err != nil {
|
||||
t.Fatalf("insert 127 alias entry: %v", err)
|
||||
}
|
||||
|
||||
// Verify both addresses appear and are healthy
|
||||
waitForAddressHealthy(t, inst.Address, defaultTimeout)
|
||||
waitForAddressHealthy(t, addr127, defaultTimeout)
|
||||
|
||||
out = listInstancesJSON(ctx, t)
|
||||
if !hasAddress(out, inst.Address) || !hasAddress(out, addr127) {
|
||||
t.Fatalf("expected both %s and %s present", inst.Address, addr127)
|
||||
}
|
||||
}
|
||||
|
||||
// 10. Start-stop stress: loop starting then killing instances; ensure no leftovers
|
||||
func TestStartStopStress(t *testing.T) {
|
||||
_ = setTempClineDir(t)
|
||||
|
||||
for i := 0; i < 3; i++ { // keep small for CI time
|
||||
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Snapshot current addresses
|
||||
before := listInstancesJSON(ctx, t)
|
||||
beforeSet := map[string]struct{}{}
|
||||
for _, it := range before.CoreInstances {
|
||||
beforeSet[it.Address] = struct{}{}
|
||||
}
|
||||
|
||||
// Start a new instance
|
||||
_ = mustRunCLI(ctx, t, "instance", "new")
|
||||
|
||||
// Find the new instance address
|
||||
var newAddr string
|
||||
waitFor(t, defaultTimeout, func() (bool, string) {
|
||||
after := listInstancesJSON(ctx, t)
|
||||
for _, it := range after.CoreInstances {
|
||||
if _, ok := beforeSet[it.Address]; !ok {
|
||||
newAddr = it.Address
|
||||
return true, ""
|
||||
}
|
||||
}
|
||||
return false, "new instance address not detected yet"
|
||||
})
|
||||
|
||||
// Wait healthy
|
||||
waitForAddressHealthy(t, newAddr, defaultTimeout)
|
||||
|
||||
// Get PID using runtime discovery and kill it
|
||||
after := listInstancesJSON(ctx, t)
|
||||
info, ok := getByAddress(after, newAddr)
|
||||
if !ok {
|
||||
t.Fatalf("new instance %s missing", newAddr)
|
||||
}
|
||||
|
||||
// Get PID using runtime discovery
|
||||
corePID := getCorePID(t, info.Address)
|
||||
if corePID <= 0 {
|
||||
t.Fatalf("could not find PID for new instance at %s", info.Address)
|
||||
}
|
||||
|
||||
t.Logf("Killing new instance %s (PID %d) for iteration %d", info.Address, corePID, i)
|
||||
if err := syscall.Kill(corePID, syscall.SIGKILL); err != nil {
|
||||
t.Fatalf("kill pid %d: %v", corePID, err)
|
||||
}
|
||||
|
||||
// Wait removed from SQLite database
|
||||
waitForAddressRemoved(t, newAddr, longTimeout)
|
||||
|
||||
// Verify instance is removed from SQLite database
|
||||
clineDir := os.Getenv("CLINE_DIR")
|
||||
if clineDir != "" {
|
||||
dbPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "locks.db")
|
||||
if verifyInstanceExistsInSQLite(t, dbPath, newAddr) {
|
||||
t.Fatalf("expected instance removed from SQLite database: %s", newAddr)
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up dangling host process (SIGKILL leaves these behind by design)
|
||||
t.Logf("Cleaning up dangling host process on port %d for iteration %d", info.HostPort(), i)
|
||||
findAndKillHostProcess(t, info.HostPort())
|
||||
|
||||
// Verify both ports are now free
|
||||
waitForPortsClosed(t, info.CorePort(), info.HostPort(), defaultTimeout)
|
||||
}
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/common"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"google.golang.org/grpc/health/grpc_health_v1"
|
||||
)
|
||||
|
||||
// readInstancesFromSQLite reads instances directly from the SQLite database for testing
|
||||
func readInstancesFromSQLite(t *testing.T, clineDir string) []common.CoreInstanceInfo {
|
||||
t.Helper()
|
||||
|
||||
dbPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "locks.db")
|
||||
|
||||
// Check if database exists
|
||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||
return []common.CoreInstanceInfo{}
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
t.Logf("Warning: Failed to open SQLite database: %v", err)
|
||||
return []common.CoreInstanceInfo{}
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Query instance locks
|
||||
query := common.SelectInstanceLockHoldersAscSQL
|
||||
|
||||
rows, err := db.Query(query)
|
||||
if err != nil {
|
||||
t.Logf("Warning: Failed to query instance locks: %v", err)
|
||||
return []common.CoreInstanceInfo{}
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var instances []common.CoreInstanceInfo
|
||||
for rows.Next() {
|
||||
var heldBy, lockTarget string
|
||||
var lockedAt int64
|
||||
|
||||
err := rows.Scan(&heldBy, &lockTarget, &lockedAt)
|
||||
if err != nil {
|
||||
t.Logf("Warning: Failed to scan lock row: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Create InstanceInfo
|
||||
info := common.CoreInstanceInfo{
|
||||
Address: heldBy,
|
||||
HostServiceAddress: lockTarget,
|
||||
Status: grpc_health_v1.HealthCheckResponse_UNKNOWN, // Will be updated by health check
|
||||
LastSeen: time.Unix(lockedAt/1000, 0), // Convert from milliseconds
|
||||
}
|
||||
|
||||
instances = append(instances, info)
|
||||
}
|
||||
|
||||
return instances
|
||||
}
|
||||
|
||||
// readDefaultInstanceFromSettings reads the default instance from the settings file
|
||||
func readDefaultInstanceFromSettings(t *testing.T, clineDir string) string {
|
||||
t.Helper()
|
||||
|
||||
settingsPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "settings", "cli-default-instance.json")
|
||||
|
||||
data, err := os.ReadFile(settingsPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return ""
|
||||
}
|
||||
t.Logf("Warning: Failed to read default instance file: %v", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
var tmp struct {
|
||||
DefaultInstance string `json:"default_instance"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &tmp); err != nil {
|
||||
t.Logf("Warning: Failed to parse default instance file: %v", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
return tmp.DefaultInstance
|
||||
}
|
||||
|
||||
// insertRemoteInstanceIntoSQLite inserts a remote instance entry directly into SQLite for testing
|
||||
func insertRemoteInstanceIntoSQLite(t *testing.T, dbPath, address string, corePort, hostPort int) error {
|
||||
t.Helper()
|
||||
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Initialize database schema for testing
|
||||
createTableSQL := `
|
||||
CREATE TABLE IF NOT EXISTS locks (
|
||||
id INTEGER PRIMARY KEY,
|
||||
held_by TEXT NOT NULL,
|
||||
lock_type TEXT NOT NULL CHECK (lock_type IN ('file', 'instance', 'folder')),
|
||||
lock_target TEXT NOT NULL,
|
||||
locked_at INTEGER NOT NULL,
|
||||
UNIQUE(lock_type, lock_target)
|
||||
);
|
||||
`
|
||||
createIndexesSQL := `
|
||||
CREATE INDEX IF NOT EXISTS idx_locks_held_by ON locks(held_by);
|
||||
CREATE INDEX IF NOT EXISTS idx_locks_type ON locks(lock_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_locks_target ON locks(lock_target);
|
||||
`
|
||||
|
||||
if _, err := db.Exec(createTableSQL); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(createIndexesSQL); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Insert the remote instance
|
||||
hostAddress := "remote.example.com:0"
|
||||
if hostPort != 0 {
|
||||
hostAddress = "remote.example.com:" + strconv.Itoa(hostPort)
|
||||
}
|
||||
|
||||
insertSQL := `INSERT INTO locks (held_by, lock_type, lock_target, locked_at) VALUES (?, 'instance', ?, ?)`
|
||||
_, err = db.Exec(insertSQL, address, hostAddress, time.Now().Unix()*1000)
|
||||
return err
|
||||
}
|
||||
|
||||
// verifyInstanceExistsInSQLite checks if an instance exists in the SQLite database
|
||||
func verifyInstanceExistsInSQLite(t *testing.T, dbPath, address string) bool {
|
||||
t.Helper()
|
||||
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
t.Logf("Failed to open database: %v", err)
|
||||
return false
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
query := `SELECT COUNT(*) FROM locks WHERE held_by = ? AND lock_type = 'instance'`
|
||||
var count int
|
||||
err = db.QueryRow(query, address).Scan(&count)
|
||||
if err != nil {
|
||||
t.Logf("Failed to query database: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
return count > 0
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"syscall"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestStartAndList verifies self-registration and default.json semantics in a fresh CLINE_DIR.
|
||||
func TestStartAndList(t *testing.T) {
|
||||
clineDir := setTempClineDir(t)
|
||||
t.Logf("Using temp CLINE_DIR: %s", clineDir)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
|
||||
defer cancel()
|
||||
|
||||
t.Logf("Starting new instance...")
|
||||
// Start a new instance
|
||||
startOutput := mustRunCLI(ctx, t, "instance", "new")
|
||||
t.Logf("Instance start output: %s", startOutput)
|
||||
|
||||
t.Logf("Listing instances to check registration...")
|
||||
// It should appear healthy in list JSON and be the default.
|
||||
out := listInstancesJSON(ctx, t)
|
||||
t.Logf("Found %d instances after start", len(out.CoreInstances))
|
||||
|
||||
if len(out.CoreInstances) != 1 {
|
||||
t.Fatalf("expected 1 instance, got %d", len(out.CoreInstances))
|
||||
}
|
||||
|
||||
addr := out.CoreInstances[0].Address
|
||||
t.Logf("Instance address: %s, status: %s", addr, out.CoreInstances[0].Status)
|
||||
|
||||
t.Logf("Waiting for address %s to become healthy...", addr)
|
||||
waitForAddressHealthy(t, addr, defaultTimeout)
|
||||
t.Logf("Address %s is now healthy", addr)
|
||||
|
||||
t.Logf("Checking default instance configuration...")
|
||||
// Default should be set to the new instance.
|
||||
out = listInstancesJSON(ctx, t)
|
||||
t.Logf("Default instance: %s", out.DefaultInstance)
|
||||
|
||||
if out.DefaultInstance == "" {
|
||||
t.Fatalf("default_instance not set")
|
||||
}
|
||||
if out.DefaultInstance != out.CoreInstances[0].Address {
|
||||
t.Fatalf("expected default_instance=%s, got %s", out.CoreInstances[0].Address, out.DefaultInstance)
|
||||
}
|
||||
|
||||
t.Logf("TestStartAndList completed successfully")
|
||||
}
|
||||
|
||||
// TestTaskNewDefault ensures tasks route to default instance.
|
||||
func TestTaskNewDefault(t *testing.T) {
|
||||
_ = setTempClineDir(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Start one instance and wait for healthy
|
||||
_ = mustRunCLI(ctx, t, "instance", "new")
|
||||
out := listInstancesJSON(ctx, t)
|
||||
if len(out.CoreInstances) != 1 {
|
||||
t.Fatalf("expected 1 instance, got %d", len(out.CoreInstances))
|
||||
}
|
||||
addr := out.CoreInstances[0].Address
|
||||
waitForAddressHealthy(t, addr, defaultTimeout)
|
||||
|
||||
// Create a new task at default (success is sufficient)
|
||||
_ = mustRunCLI(ctx, t, "task", "new", "hello world")
|
||||
}
|
||||
|
||||
// TestExplicitAddressAutoStart verifies that giving an explicit address auto-starts an instance and routes the task.
|
||||
func TestExplicitAddressAutoStart(t *testing.T) {
|
||||
_ = setTempClineDir(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Find a free port and use explicit address. This should auto-start an instance.
|
||||
port := findFreePort(t)
|
||||
addr := "localhost:" + itoa(port)
|
||||
|
||||
// Run a task at explicit address (auto-start path)
|
||||
_ = mustRunCLI(ctx, t, "task", "new", "--address", "localhost:"+itoa(port), "explicit address task")
|
||||
|
||||
// Verify the instance is present and healthy
|
||||
waitForAddressHealthy(t, addr, defaultTimeout)
|
||||
}
|
||||
|
||||
// TestCrashCleanup verifies that after SIGKILL of a local core, the cleanup removes the registry entry.
|
||||
// Also tests graceful shutdown (SIGTERM) vs crash cleanup and ensures no dangling host processes.
|
||||
func TestCrashCleanup(t *testing.T) {
|
||||
_ = setTempClineDir(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Start two instances for testing both graceful and crash scenarios
|
||||
_ = mustRunCLI(ctx, t, "instance", "new")
|
||||
_ = mustRunCLI(ctx, t, "instance", "new")
|
||||
|
||||
out := listInstancesJSON(ctx, t)
|
||||
if len(out.CoreInstances) < 2 {
|
||||
t.Fatalf("expected at least 2 instances, got %d", len(out.CoreInstances))
|
||||
}
|
||||
|
||||
// Test 1: Graceful shutdown (SIGTERM) - should clean up both processes
|
||||
gracefulTarget := out.CoreInstances[0]
|
||||
waitForAddressHealthy(t, gracefulTarget.Address, defaultTimeout)
|
||||
|
||||
// Get PID using runtime discovery
|
||||
gracefulPID := getCorePID(t, gracefulTarget.Address)
|
||||
if gracefulPID <= 0 {
|
||||
t.Fatalf("could not find PID for graceful target at %s", gracefulTarget.Address)
|
||||
}
|
||||
|
||||
t.Logf("Testing graceful shutdown (SIGTERM) for instance %s (PID %d)", gracefulTarget.Address, gracefulPID)
|
||||
if err := syscall.Kill(gracefulPID, syscall.SIGTERM); err != nil {
|
||||
t.Fatalf("kill SIGTERM pid %d: %v", gracefulPID, err)
|
||||
}
|
||||
|
||||
// Wait for registry cleanup
|
||||
waitForAddressRemoved(t, gracefulTarget.Address, longTimeout)
|
||||
|
||||
// Verify both core and host ports are freed (no dangling processes)
|
||||
waitForPortsClosed(t, gracefulTarget.CorePort(), gracefulTarget.HostPort(), defaultTimeout)
|
||||
|
||||
// Verify the instance is removed from SQLite (no file to check anymore)
|
||||
// The waitForAddressRemoved already confirms the instance is gone from the registry
|
||||
|
||||
// Test 2: Crash cleanup (SIGKILL) - creates dangling host process that we must clean up
|
||||
crashTarget := out.CoreInstances[1]
|
||||
waitForAddressHealthy(t, crashTarget.Address, defaultTimeout)
|
||||
|
||||
// Get PID using runtime discovery
|
||||
crashPID := getCorePID(t, crashTarget.Address)
|
||||
if crashPID <= 0 {
|
||||
t.Fatalf("could not find PID for crash target at %s", crashTarget.Address)
|
||||
}
|
||||
|
||||
t.Logf("Testing crash cleanup (SIGKILL) for instance %s (PID %d)", crashTarget.Address, crashPID)
|
||||
if err := syscall.Kill(crashPID, syscall.SIGKILL); err != nil {
|
||||
t.Fatalf("kill SIGKILL pid %d: %v", crashPID, err)
|
||||
}
|
||||
|
||||
// Wait for registry cleanup
|
||||
waitForAddressRemoved(t, crashTarget.Address, longTimeout)
|
||||
|
||||
// Verify the instance is removed from SQLite (no file to check anymore)
|
||||
// The waitForAddressRemoved already confirms the instance is gone from the registry
|
||||
|
||||
// Clean up dangling host process (SIGKILL leaves these behind by design)
|
||||
t.Logf("Cleaning up dangling host process %s", crashTarget.HostServiceAddress)
|
||||
findAndKillHostProcess(t, crashTarget.HostPort())
|
||||
|
||||
// Verify both ports are now free
|
||||
waitForPortsClosed(t, crashTarget.CorePort(), crashTarget.HostPort(), defaultTimeout)
|
||||
}
|
||||
|
||||
// itoa is a small helper for readability
|
||||
func itoa(i int) string {
|
||||
return strconvItoa(i)
|
||||
}
|
||||
|
||||
// minimal inline int->string to avoid extra imports in helpers
|
||||
func strconvItoa(i int) string {
|
||||
// simple fast path
|
||||
return fmtInt(i)
|
||||
}
|
||||
|
||||
func fmtInt(i int) string {
|
||||
// allocate small buffer; ints here are short
|
||||
return (func(n int) string {
|
||||
return fmt.Sprintf("%d", n)
|
||||
})(i)
|
||||
}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
module github.com/cline/cli
|
||||
|
||||
go 1.23.0
|
||||
|
||||
require (
|
||||
github.com/cline/grpc-go v0.0.0
|
||||
github.com/mattn/go-sqlite3 v1.14.24
|
||||
github.com/spf13/cobra v1.8.0
|
||||
google.golang.org/grpc v1.75.0
|
||||
)
|
||||
|
||||
replace github.com/cline/grpc-go => ../src/generated/grpc-go
|
||||
|
||||
require (
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
golang.org/x/net v0.41.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/text v0.26.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
)
|
||||
-48
@@ -1,48 +0,0 @@
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
||||
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
|
||||
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
|
||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 h1:pFyd6EwwL2TqFf8emdthzeX+gZE1ElRq3iM8pui4KBY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||
google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4=
|
||||
google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -1,125 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var isSessionAuthenticated bool
|
||||
|
||||
func NewAuthCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "auth",
|
||||
Short: "Sign in to Cline",
|
||||
Long: `Complete the authentication flow in browser to sign in to Cline.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return handleAuthCommand(cmd.Context())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func handleAuthCommand(ctx context.Context) error {
|
||||
fmt.Print("Authenticating with Cline...\n")
|
||||
if IsAuthenticated(ctx) {
|
||||
return signOutDialog(ctx)
|
||||
}
|
||||
|
||||
if err := signIn(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("You are signed in!")
|
||||
return nil
|
||||
}
|
||||
|
||||
func signOut(ctx context.Context) error {
|
||||
client, err := global.GetDefaultClient(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err = client.Account.AccountLogoutClicked(ctx, &cline.EmptyRequest{}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
isSessionAuthenticated = false
|
||||
fmt.Println("You have been signed out of Cline.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func signOutDialog(ctx context.Context) error {
|
||||
fmt.Print("You are already signed in to Cline.\nWould you like to sign out? (y/N): ")
|
||||
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
if !scanner.Scan() {
|
||||
return nil
|
||||
}
|
||||
|
||||
response := strings.ToLower(strings.TrimSpace(scanner.Text()))
|
||||
if response == "y" || response == "yes" {
|
||||
if err := signOut(ctx); err != nil {
|
||||
fmt.Printf("Failed to sign out: %v\n", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func signIn(ctx context.Context) error {
|
||||
if IsAuthenticated(ctx) {
|
||||
return nil
|
||||
}
|
||||
|
||||
verboseLog("Ensuring default instance exists...")
|
||||
if err := ensureDefaultInstance(ctx); err != nil {
|
||||
verboseLog("Failed to ensure default instance: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
verboseLog("Default instance ensured successfully.")
|
||||
time.Sleep(2 * time.Second) // Allow services to start
|
||||
|
||||
client, err := global.GetDefaultClient(ctx)
|
||||
if err != nil {
|
||||
verboseLog("Failed to obtain client: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = client.Account.AccountLoginClicked(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
verboseLog("Failed to login: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
isSessionAuthenticated = true
|
||||
verboseLog("Login successful")
|
||||
return nil
|
||||
}
|
||||
|
||||
func IsAuthenticated(ctx context.Context) bool {
|
||||
if isSessionAuthenticated {
|
||||
return true
|
||||
}
|
||||
|
||||
client, err := global.GetDefaultClient(ctx)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
_, err = client.Account.GetUserCredits(ctx, &cline.EmptyRequest{})
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func verboseLog(format string, args ...interface{}) {
|
||||
if global.Config != nil && global.Config.Verbose {
|
||||
fmt.Printf("[VERBOSE] "+format+"\n", args...)
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
)
|
||||
|
||||
// MessageDeduplicator handles message deduplication to prevent duplicate displays
|
||||
type MessageDeduplicator struct {
|
||||
mu sync.RWMutex
|
||||
seenMessages map[string]time.Time
|
||||
maxAge time.Duration
|
||||
cleanupTicker *time.Ticker
|
||||
}
|
||||
|
||||
// NewMessageDeduplicator creates a new message deduplicator
|
||||
func NewMessageDeduplicator() *MessageDeduplicator {
|
||||
d := &MessageDeduplicator{
|
||||
seenMessages: make(map[string]time.Time),
|
||||
maxAge: 5 * time.Minute, // Keep messages for 5 minutes
|
||||
cleanupTicker: time.NewTicker(1 * time.Minute), // Cleanup every minute
|
||||
}
|
||||
|
||||
// Start cleanup goroutine
|
||||
go d.cleanup()
|
||||
|
||||
return d
|
||||
}
|
||||
|
||||
// IsDuplicate checks if a message is a duplicate
|
||||
func (d *MessageDeduplicator) IsDuplicate(msg *types.ClineMessage) bool {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
// Create a hash of the message content
|
||||
hash := d.hashMessage(msg)
|
||||
|
||||
// Check if we've seen this message recently
|
||||
if lastSeen, exists := d.seenMessages[hash]; exists {
|
||||
// If we've seen it within the last few seconds, it's a duplicate
|
||||
if time.Since(lastSeen) < 2*time.Second {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Mark this message as seen
|
||||
d.seenMessages[hash] = time.Now()
|
||||
return false
|
||||
}
|
||||
|
||||
// hashMessage creates a hash of the message for deduplication
|
||||
func (d *MessageDeduplicator) hashMessage(msg *types.ClineMessage) string {
|
||||
// Create a hash based on message content, type, and timestamp
|
||||
content := fmt.Sprintf("%s|%s|%s|%d",
|
||||
string(msg.Type),
|
||||
msg.Say,
|
||||
msg.Ask,
|
||||
msg.Timestamp)
|
||||
|
||||
// For partial messages, include the text content in the hash
|
||||
if msg.Partial {
|
||||
content += "|" + msg.Text
|
||||
}
|
||||
|
||||
hash := md5.Sum([]byte(content))
|
||||
return fmt.Sprintf("%x", hash)
|
||||
}
|
||||
|
||||
// cleanup removes old entries from the seen messages map
|
||||
func (d *MessageDeduplicator) cleanup() {
|
||||
for range d.cleanupTicker.C {
|
||||
d.mu.Lock()
|
||||
now := time.Now()
|
||||
|
||||
// Remove entries older than maxAge
|
||||
for hash, timestamp := range d.seenMessages {
|
||||
if now.Sub(timestamp) > d.maxAge {
|
||||
delete(d.seenMessages, hash)
|
||||
}
|
||||
}
|
||||
|
||||
d.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Stop stops the cleanup goroutine
|
||||
func (d *MessageDeduplicator) Stop() {
|
||||
if d.cleanupTicker != nil {
|
||||
d.cleanupTicker.Stop()
|
||||
}
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
type Renderer struct {
|
||||
typewriter *TypewriterPrinter
|
||||
}
|
||||
|
||||
func NewRenderer() *Renderer {
|
||||
return &Renderer{
|
||||
typewriter: NewTypewriterPrinter(DefaultTypewriterConfig()),
|
||||
}
|
||||
}
|
||||
|
||||
// RenderMessage renders a message with timestamp and prefix
|
||||
func (r *Renderer) RenderMessage(timestamp, prefix, text string) error {
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
cleanText := r.sanitizeText(text)
|
||||
if cleanText == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
r.typewriter.PrintMessageLine(timestamp, prefix, cleanText)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenderCommand renders a command execution
|
||||
func (r *Renderer) RenderCommand(timestamp, command string, isExecuting bool) error {
|
||||
if isExecuting {
|
||||
r.typewriter.PrintMessageLine(timestamp, "EXEC", command)
|
||||
} else {
|
||||
r.typewriter.PrintMessageLine(timestamp, "CMD", command)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatNumber formats numbers with k/m abbreviations
|
||||
func formatNumber(n int) string {
|
||||
if n >= 1000000 {
|
||||
return fmt.Sprintf("%.1fm", float64(n)/1000000.0)
|
||||
} else if n >= 1000 {
|
||||
return fmt.Sprintf("%.1fk", float64(n)/1000.0)
|
||||
}
|
||||
return fmt.Sprintf("%d", n)
|
||||
}
|
||||
|
||||
// formatUsageInfo formats token usage information (extracted from RenderAPI)
|
||||
func (r *Renderer) formatUsageInfo(tokensIn, tokensOut, cacheReads, cacheWrites int, cost float64) string {
|
||||
tokenDetails := fmt.Sprintf("[tokens in: %s, out: %s; cache read: %s, write: %s]",
|
||||
formatNumber(tokensIn),
|
||||
formatNumber(tokensOut),
|
||||
formatNumber(cacheReads),
|
||||
formatNumber(cacheWrites))
|
||||
|
||||
return fmt.Sprintf("%s ($%.4f)", tokenDetails, cost)
|
||||
}
|
||||
|
||||
// RenderAPI renders API request information
|
||||
func (r *Renderer) RenderAPI(timestamp, status string, apiInfo *types.APIRequestInfo) error {
|
||||
if apiInfo.Cost >= 0 {
|
||||
message := fmt.Sprintf("%s %s", status, r.formatUsageInfo(apiInfo.TokensIn, apiInfo.TokensOut, apiInfo.CacheReads, apiInfo.CacheWrites, apiInfo.Cost))
|
||||
r.typewriter.PrintMessageLine(timestamp, "API INFO", message)
|
||||
} else {
|
||||
r.typewriter.PrintMessageLine(timestamp, "API INFO", status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenderRetry renders retry information
|
||||
func (r *Renderer) RenderRetry(timestamp string, attempt, maxAttempts, delaySec int) error {
|
||||
message := fmt.Sprintf("Retrying failed attempt %d/%d", attempt, maxAttempts)
|
||||
if delaySec > 0 {
|
||||
message += fmt.Sprintf(" in %d seconds", delaySec)
|
||||
}
|
||||
message += "..."
|
||||
r.typewriter.PrintMessageLine(timestamp, "API INFO", message)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenderTaskList displays task history with improved formatting
|
||||
func (r *Renderer) RenderTaskList(tasks []*cline.TaskItem) error {
|
||||
const maxTasks = 20
|
||||
|
||||
startIndex := 0
|
||||
if len(tasks) > maxTasks {
|
||||
startIndex = len(tasks) - maxTasks
|
||||
}
|
||||
|
||||
recentTasks := tasks[startIndex:]
|
||||
|
||||
r.typewriter.PrintfLn("=== Task History (showing last %d of %d total tasks) ===\n", len(recentTasks), len(tasks))
|
||||
|
||||
for i, task := range recentTasks {
|
||||
r.typewriter.PrintfLn("Task ID: %s", task.Id)
|
||||
|
||||
description := task.Task
|
||||
if len(description) > 1000 {
|
||||
description = description[:1000] + "..."
|
||||
}
|
||||
r.typewriter.PrintfLn("Message: %s", description)
|
||||
|
||||
usageInfo := r.formatUsageInfo(int(task.TokensIn), int(task.TokensOut), int(task.CacheReads), int(task.CacheWrites), task.TotalCost)
|
||||
r.typewriter.PrintfLn("Usage : %s", usageInfo)
|
||||
|
||||
// Single space between tasks (except last)
|
||||
if i < len(recentTasks)-1 {
|
||||
r.typewriter.PrintfLn("")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Renderer) RenderDebug(format string, args ...interface{}) error {
|
||||
if global.Config.Verbose {
|
||||
timestamp := time.Now().Format("15:04:05")
|
||||
message := fmt.Sprintf(format, args...)
|
||||
r.typewriter.PrintMessageLine(timestamp, "[DEBUG]", message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Renderer) ClearLine() {
|
||||
fmt.Print("\r\033[K")
|
||||
}
|
||||
|
||||
func (r *Renderer) MoveCursorUp(n int) {
|
||||
fmt.Printf("\033[%dA", n)
|
||||
}
|
||||
|
||||
func (r *Renderer) sanitizeText(text string) string {
|
||||
text = strings.TrimSpace(text)
|
||||
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Remove control characters and escape sequences
|
||||
var result strings.Builder
|
||||
for _, r := range text {
|
||||
// Keep printable characters, spaces, tabs, and newlines
|
||||
if r >= 32 || r == '\t' || r == '\n' || r == '\r' {
|
||||
result.WriteRune(r)
|
||||
}
|
||||
// Skip control characters (0-31 except tab, newline, carriage return)
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
func (r *Renderer) SetTypewriterEnabled(enabled bool) {
|
||||
r.typewriter.SetEnabled(enabled)
|
||||
}
|
||||
|
||||
func (r *Renderer) IsTypewriterEnabled() bool {
|
||||
return r.typewriter.IsEnabled()
|
||||
}
|
||||
|
||||
func (r *Renderer) SetTypewriterSpeed(multiplier float64) {
|
||||
r.typewriter.SetSpeed(multiplier)
|
||||
}
|
||||
|
||||
func (r *Renderer) GetTypewriter() *TypewriterPrinter {
|
||||
return r.typewriter
|
||||
}
|
||||
@@ -1,449 +0,0 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
)
|
||||
|
||||
// StreamingDisplay manages streaming message display with deduplication
|
||||
type StreamingDisplay struct {
|
||||
mu sync.RWMutex
|
||||
state *types.ConversationState
|
||||
renderer *Renderer
|
||||
dedupe *MessageDeduplicator
|
||||
}
|
||||
|
||||
// NewStreamingDisplay creates a new streaming display manager
|
||||
func NewStreamingDisplay(state *types.ConversationState, renderer *Renderer) *StreamingDisplay {
|
||||
return &StreamingDisplay{
|
||||
state: state,
|
||||
renderer: renderer,
|
||||
dedupe: NewMessageDeduplicator(),
|
||||
}
|
||||
}
|
||||
|
||||
// HandlePartialMessage processes partial messages with streaming support
|
||||
func (s *StreamingDisplay) HandlePartialMessage(msg *types.ClineMessage) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
messageKey := fmt.Sprintf("%d", msg.Timestamp)
|
||||
timestamp := msg.GetTimestamp()
|
||||
|
||||
// Check for deduplication
|
||||
if s.dedupe.IsDuplicate(msg) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get current streaming state
|
||||
streamingMsg := s.state.GetStreamingMessage()
|
||||
|
||||
switch msg.Type {
|
||||
case types.MessageTypeAsk:
|
||||
return s.handleStreamingAsk(msg, messageKey, timestamp, streamingMsg)
|
||||
case types.MessageTypeSay:
|
||||
return s.handleStreamingSay(msg, messageKey, timestamp, streamingMsg)
|
||||
default:
|
||||
return s.renderer.RenderMessage(timestamp, "🤖", msg.Text)
|
||||
}
|
||||
}
|
||||
|
||||
// handleStreamingAsk handles streaming ASK messages
|
||||
func (s *StreamingDisplay) handleStreamingAsk(msg *types.ClineMessage, messageKey, timestamp string, streamingMsg *types.StreamingMessage) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
cleanText := s.renderer.sanitizeText(msg.Text)
|
||||
if cleanText == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if this is an update to the same ASK message
|
||||
if streamingMsg.CurrentKey == messageKey {
|
||||
// This is an update to the same ASK message - stream the changes
|
||||
if cleanText != streamingMsg.LastText {
|
||||
s.streamAskMessageUpdate(cleanText, streamingMsg.LastText, timestamp)
|
||||
s.state.SetStreamingMessage(messageKey, cleanText)
|
||||
}
|
||||
} else {
|
||||
// This is a new ASK message
|
||||
s.finishCurrentStream()
|
||||
s.streamAskMessage(cleanText, timestamp, true)
|
||||
s.state.SetStreamingMessage(messageKey, cleanText)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleStreamingSay handles streaming SAY messages
|
||||
func (s *StreamingDisplay) handleStreamingSay(msg *types.ClineMessage, messageKey, timestamp string, streamingMsg *types.StreamingMessage) error {
|
||||
switch msg.Say {
|
||||
case string(types.SayTypeText), string(types.SayTypeCompletionResult):
|
||||
return s.handleStreamingText(msg, messageKey, timestamp, streamingMsg)
|
||||
case string(types.SayTypeCommand):
|
||||
return s.handleStreamingCommand(msg, messageKey, timestamp, streamingMsg)
|
||||
case string(types.SayTypeCommandOutput):
|
||||
return s.handleStreamingCommandOutput(msg, messageKey, timestamp, streamingMsg)
|
||||
case string(types.SayTypeTool):
|
||||
return s.handleStreamingTool(msg, messageKey, timestamp, streamingMsg)
|
||||
case string(types.SayTypeShellIntegrationWarning):
|
||||
return s.handleShellIntegrationWarning(msg, messageKey, timestamp, streamingMsg)
|
||||
default:
|
||||
// For non-streaming message types, use regular display
|
||||
return s.renderer.RenderMessage(timestamp, s.getMessagePrefix(msg.Say), msg.Text)
|
||||
}
|
||||
}
|
||||
|
||||
// handleStreamingText handles streaming text messages
|
||||
func (s *StreamingDisplay) handleStreamingText(msg *types.ClineMessage, messageKey, timestamp string, streamingMsg *types.StreamingMessage) error {
|
||||
cleanText := s.renderer.sanitizeText(msg.Text)
|
||||
if cleanText == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if we've already displayed this exact message
|
||||
if streamingMsg.CurrentKey == messageKey && streamingMsg.LastText == cleanText {
|
||||
return nil // Duplicate - ignore it
|
||||
}
|
||||
|
||||
// Check if this is an update to the same message
|
||||
if streamingMsg.CurrentKey == messageKey {
|
||||
// Show incremental changes
|
||||
if len(cleanText) > len(streamingMsg.LastText) && strings.HasPrefix(cleanText, streamingMsg.LastText) {
|
||||
// Show only the new characters with typewriter effect
|
||||
newChars := cleanText[len(streamingMsg.LastText):]
|
||||
s.typewriterPrint(newChars)
|
||||
s.state.SetStreamingMessage(messageKey, cleanText)
|
||||
} else {
|
||||
// Text changed in a non-incremental way - replace the line
|
||||
s.renderer.ClearLine()
|
||||
prefix := s.getMessagePrefix(msg.Say)
|
||||
s.renderer.typewriter.PrintfInstant("[%s] %s: ", timestamp, prefix)
|
||||
s.typewriterPrint(cleanText)
|
||||
s.state.SetStreamingMessage(messageKey, cleanText)
|
||||
}
|
||||
} else {
|
||||
// This is a new message
|
||||
s.finishCurrentStream()
|
||||
prefix := s.getMessagePrefix(msg.Say)
|
||||
s.renderer.typewriter.PrintfInstant("[%s] %s: ", timestamp, prefix)
|
||||
|
||||
// Add typewriter animation for new messages
|
||||
s.typewriterPrint(cleanText)
|
||||
|
||||
s.state.SetStreamingMessage(messageKey, cleanText)
|
||||
}
|
||||
|
||||
// If message is complete, add newline
|
||||
if !msg.Partial {
|
||||
fmt.Println()
|
||||
s.state.SetStreamingMessage("", "")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleStreamingCommand handles command execution messages
|
||||
func (s *StreamingDisplay) handleStreamingCommand(msg *types.ClineMessage, messageKey, timestamp string, streamingMsg *types.StreamingMessage) error {
|
||||
cleanText := s.renderer.sanitizeText(msg.Text)
|
||||
if cleanText == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Show command being executed with typewriter effect
|
||||
s.finishCurrentStream()
|
||||
s.renderer.typewriter.PrintfInstant("[%s] 🖥️ CMD: ", timestamp)
|
||||
s.typewriterPrint(cleanText)
|
||||
fmt.Println()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleStreamingCommandOutput handles streaming command output
|
||||
func (s *StreamingDisplay) handleStreamingCommandOutput(msg *types.ClineMessage, messageKey, timestamp string, streamingMsg *types.StreamingMessage) error {
|
||||
cleanText := s.renderer.sanitizeText(msg.Text)
|
||||
if cleanText == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if we've already displayed this exact message
|
||||
if streamingMsg.CurrentKey == messageKey && streamingMsg.LastText == cleanText {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if this is an update to the same message
|
||||
if streamingMsg.CurrentKey == messageKey {
|
||||
// Show incremental changes with typewriter effect
|
||||
if len(cleanText) > len(streamingMsg.LastText) && strings.HasPrefix(cleanText, streamingMsg.LastText) {
|
||||
newChars := cleanText[len(streamingMsg.LastText):]
|
||||
s.typewriterPrint(newChars)
|
||||
s.state.SetStreamingMessage(messageKey, cleanText)
|
||||
} else {
|
||||
// Non-incremental change - replace the line
|
||||
s.renderer.ClearLine()
|
||||
s.renderer.typewriter.PrintfInstant("[%s] 🖥️ OUT: ", timestamp)
|
||||
s.typewriterPrint(cleanText)
|
||||
s.state.SetStreamingMessage(messageKey, cleanText)
|
||||
}
|
||||
} else {
|
||||
// New command output message
|
||||
s.finishCurrentStream()
|
||||
s.renderer.typewriter.PrintfInstant("[%s] 🖥️ OUT: ", timestamp)
|
||||
s.typewriterPrint(cleanText)
|
||||
s.state.SetStreamingMessage(messageKey, cleanText)
|
||||
}
|
||||
|
||||
// If message is complete, add newline
|
||||
if !msg.Partial {
|
||||
fmt.Println()
|
||||
s.state.SetStreamingMessage("", "")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleShellIntegrationWarning handles shell integration warning messages
|
||||
func (s *StreamingDisplay) handleShellIntegrationWarning(msg *types.ClineMessage, messageKey, timestamp string, streamingMsg *types.StreamingMessage) error {
|
||||
cleanText := s.renderer.sanitizeText(msg.Text)
|
||||
if cleanText == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Show a more concise shell integration warning
|
||||
s.finishCurrentStream()
|
||||
s.renderer.typewriter.PrintfInstant("[%s] ℹ️ NOTE: ", timestamp)
|
||||
s.typewriterPrint("Command executed (output not streamed due to shell integration)")
|
||||
fmt.Println()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleStreamingTool handles streaming tool messages with deduplication
|
||||
func (s *StreamingDisplay) handleStreamingTool(msg *types.ClineMessage, messageKey, timestamp string, streamingMsg *types.StreamingMessage) error {
|
||||
cleanText := s.renderer.sanitizeText(msg.Text)
|
||||
if cleanText == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
formattedTool := s.formatToolMessage(cleanText)
|
||||
|
||||
// Check if this is the exact same tool message we just displayed
|
||||
if streamingMsg.LastToolMessage == formattedTool {
|
||||
return nil // Exact duplicate - ignore it
|
||||
}
|
||||
|
||||
// Check if this is a very similar tool message
|
||||
if streamingMsg.LastToolMessage != "" && s.isSimilarToolMessage(streamingMsg.LastToolMessage, formattedTool) {
|
||||
return nil // Similar duplicate - ignore it
|
||||
}
|
||||
|
||||
// This is a genuinely new/different tool message
|
||||
s.finishCurrentStream()
|
||||
fmt.Printf("[%s] 🔧 TOOL: %s\n", timestamp, formattedTool)
|
||||
|
||||
// Store the formatted tool message for deduplication
|
||||
s.state.StreamingMessage.LastToolMessage = formattedTool
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// streamAskMessage streams an ASK message in a natural format
|
||||
func (s *StreamingDisplay) streamAskMessage(text, timestamp string, isNew bool) {
|
||||
// Try to parse as JSON
|
||||
var askData types.AskData
|
||||
if err := s.parseJSON(text, &askData); err != nil {
|
||||
// Display as text but sanitized
|
||||
fmt.Printf("[%s] 🤖 ASK: %s", timestamp, text)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("[%s] 🤖 ASK: %s", timestamp, askData.Response)
|
||||
|
||||
// Display options if available
|
||||
if len(askData.Options) > 0 {
|
||||
fmt.Print("\n\nOptions:")
|
||||
for i, option := range askData.Options {
|
||||
fmt.Printf("\n%d. %s", i+1, option)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// streamAskMessageUpdate handles updates to an existing ASK message
|
||||
func (s *StreamingDisplay) streamAskMessageUpdate(newText, oldText, timestamp string) {
|
||||
var oldAskData, newAskData types.AskData
|
||||
|
||||
oldErr := s.parseJSON(oldText, &oldAskData)
|
||||
newErr := s.parseJSON(newText, &newAskData)
|
||||
|
||||
if oldErr != nil || newErr != nil {
|
||||
// Handle plain text incremental updates
|
||||
if len(newText) > len(oldText) && strings.HasPrefix(newText, oldText) {
|
||||
newChars := newText[len(oldText):]
|
||||
fmt.Print(newChars)
|
||||
} else {
|
||||
// Non-incremental change - clear line and reprint everything
|
||||
s.renderer.ClearLine()
|
||||
fmt.Printf("[%s] 🤖 ASK: %s", timestamp, newText)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle structured updates
|
||||
if len(newAskData.Response) > len(oldAskData.Response) && strings.HasPrefix(newAskData.Response, oldAskData.Response) {
|
||||
newChars := newAskData.Response[len(oldAskData.Response):]
|
||||
fmt.Print(newChars)
|
||||
} else if oldAskData.Response != newAskData.Response {
|
||||
s.renderer.ClearLine()
|
||||
fmt.Printf("[%s] 🤖 ASK: %s", timestamp, newAskData.Response)
|
||||
}
|
||||
|
||||
// Handle options changes
|
||||
if len(newAskData.Options) > len(oldAskData.Options) {
|
||||
if len(oldAskData.Options) == 0 {
|
||||
fmt.Print("\n\nOptions:")
|
||||
}
|
||||
|
||||
for i := len(oldAskData.Options); i < len(newAskData.Options); i++ {
|
||||
fmt.Printf("\n%d. %s", i+1, newAskData.Options[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// typewriterPrint displays text with a typewriter animation effect
|
||||
func (s *StreamingDisplay) typewriterPrint(text string) {
|
||||
// Use the renderer's typewriter for consistent animation
|
||||
s.renderer.typewriter.Print(text)
|
||||
}
|
||||
|
||||
// finishCurrentStream completes any ongoing streaming message
|
||||
func (s *StreamingDisplay) finishCurrentStream() {
|
||||
streamingMsg := s.state.GetStreamingMessage()
|
||||
if streamingMsg.CurrentKey != "" {
|
||||
//fmt.Println() // Add newline to finish the current streaming message
|
||||
s.state.SetStreamingMessage("", "")
|
||||
}
|
||||
}
|
||||
|
||||
// getMessagePrefix returns the appropriate prefix for a message type
|
||||
func (s *StreamingDisplay) getMessagePrefix(say string) string {
|
||||
switch say {
|
||||
case string(types.SayTypeCompletionResult):
|
||||
return "✅ RESULT"
|
||||
case string(types.SayTypeText):
|
||||
return "🤖"
|
||||
default:
|
||||
return "🤖"
|
||||
}
|
||||
}
|
||||
|
||||
// formatToolMessage formats tool call messages for better readability
|
||||
func (s *StreamingDisplay) formatToolMessage(text string) string {
|
||||
var toolCall map[string]interface{}
|
||||
if err := s.parseJSON(text, &toolCall); err == nil {
|
||||
if tool, ok := toolCall["tool"].(string); ok {
|
||||
parts := []string{tool}
|
||||
|
||||
if path, ok := toolCall["path"].(string); ok && path != "" {
|
||||
parts = append(parts, fmt.Sprintf("path=%s", path))
|
||||
}
|
||||
|
||||
if content, ok := toolCall["content"].(string); ok && content != "" {
|
||||
if len(content) > 50 {
|
||||
parts = append(parts, fmt.Sprintf("content=%s...", content[:50]))
|
||||
} else {
|
||||
parts = append(parts, fmt.Sprintf("content=%s", content))
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
}
|
||||
|
||||
// If not JSON or doesn't have expected structure, return truncated
|
||||
if len(text) > 100 {
|
||||
return text[:100] + "..."
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// isSimilarToolMessage checks if two tool messages are similar enough to be considered duplicates
|
||||
func (s *StreamingDisplay) isSimilarToolMessage(msg1, msg2 string) bool {
|
||||
parts1 := strings.Fields(msg1)
|
||||
parts2 := strings.Fields(msg2)
|
||||
|
||||
if len(parts1) == 0 || len(parts2) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// If the first word (tool name) is the same, check for similarity
|
||||
if parts1[0] == parts2[0] {
|
||||
// For file operations, check if the path is the same
|
||||
if strings.Contains(msg1, "path=") && strings.Contains(msg2, "path=") {
|
||||
path1 := s.extractPathFromToolMessage(msg1)
|
||||
path2 := s.extractPathFromToolMessage(msg2)
|
||||
|
||||
if path1 != "" && path1 == path2 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// For very similar content (>80% similarity), consider them duplicates
|
||||
similarity := s.calculateStringSimilarity(msg1, msg2)
|
||||
return similarity > 0.8
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// extractPathFromToolMessage extracts the path parameter from a tool message
|
||||
func (s *StreamingDisplay) extractPathFromToolMessage(msg string) string {
|
||||
parts := strings.Fields(msg)
|
||||
for _, part := range parts {
|
||||
if strings.HasPrefix(part, "path=") {
|
||||
return strings.TrimPrefix(part, "path=")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// calculateStringSimilarity calculates a simple similarity ratio between two strings
|
||||
func (s *StreamingDisplay) calculateStringSimilarity(s1, s2 string) float64 {
|
||||
if s1 == s2 {
|
||||
return 1.0
|
||||
}
|
||||
|
||||
if len(s1) == 0 || len(s2) == 0 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
shorter, longer := s1, s2
|
||||
if len(s1) > len(s2) {
|
||||
shorter, longer = s2, s1
|
||||
}
|
||||
|
||||
matches := 0
|
||||
for i, r := range shorter {
|
||||
if i < len(longer) && rune(longer[i]) == r {
|
||||
matches++
|
||||
}
|
||||
}
|
||||
|
||||
return float64(matches) / float64(len(longer))
|
||||
}
|
||||
|
||||
// parseJSON is a helper function to parse JSON with error handling
|
||||
func (s *StreamingDisplay) parseJSON(text string, v interface{}) error {
|
||||
return json.Unmarshal([]byte(text), v)
|
||||
}
|
||||
|
||||
// Cleanup cleans up streaming display resources
|
||||
func (s *StreamingDisplay) Cleanup() {
|
||||
if s.dedupe != nil {
|
||||
s.dedupe.Stop()
|
||||
}
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TypewriterConfig holds configuration for the typewriter effect
|
||||
type TypewriterConfig struct {
|
||||
BaseDelay time.Duration // Base delay between characters
|
||||
FastDelay time.Duration // Faster delay for common characters
|
||||
SlowDelay time.Duration // Slower delay for punctuation
|
||||
PauseDelay time.Duration // Pause after sentences
|
||||
Enabled bool // Whether typewriter effect is enabled
|
||||
RandomFactor float64 // Randomness factor (0.0 to 1.0)
|
||||
}
|
||||
|
||||
// DefaultTypewriterConfig returns the default typewriter configuration
|
||||
func DefaultTypewriterConfig() *TypewriterConfig {
|
||||
return &TypewriterConfig{
|
||||
BaseDelay: 15 * time.Millisecond,
|
||||
FastDelay: 8 * time.Millisecond,
|
||||
SlowDelay: 25 * time.Millisecond,
|
||||
PauseDelay: 150 * time.Millisecond,
|
||||
Enabled: false,
|
||||
RandomFactor: 0.3,
|
||||
}
|
||||
}
|
||||
|
||||
// TypewriterPrinter handles typewriter-style output
|
||||
type TypewriterPrinter struct {
|
||||
config *TypewriterConfig
|
||||
}
|
||||
|
||||
// NewTypewriterPrinter creates a new typewriter printer
|
||||
func NewTypewriterPrinter(config *TypewriterConfig) *TypewriterPrinter {
|
||||
if config == nil {
|
||||
config = DefaultTypewriterConfig()
|
||||
}
|
||||
return &TypewriterPrinter{
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
// Print prints text with typewriter effect
|
||||
func (tp *TypewriterPrinter) Print(text string) {
|
||||
if !tp.config.Enabled {
|
||||
fmt.Print(text)
|
||||
return
|
||||
}
|
||||
|
||||
tp.typewriterPrint(text)
|
||||
}
|
||||
|
||||
// Printf prints formatted text with typewriter effect
|
||||
func (tp *TypewriterPrinter) Printf(format string, args ...interface{}) {
|
||||
text := fmt.Sprintf(format, args...)
|
||||
tp.Print(text)
|
||||
}
|
||||
|
||||
// Println prints text with typewriter effect and adds a newline
|
||||
func (tp *TypewriterPrinter) Println(text string) {
|
||||
tp.Print(text + "\n")
|
||||
}
|
||||
|
||||
// PrintfLn prints formatted text with typewriter effect and adds a newline
|
||||
func (tp *TypewriterPrinter) PrintfLn(format string, args ...interface{}) {
|
||||
text := fmt.Sprintf(format, args...)
|
||||
tp.Println(text)
|
||||
}
|
||||
|
||||
// PrintInstant prints text immediately without typewriter effect
|
||||
func (tp *TypewriterPrinter) PrintInstant(text string) {
|
||||
fmt.Print(text)
|
||||
}
|
||||
|
||||
// PrintfInstant prints formatted text immediately without typewriter effect
|
||||
func (tp *TypewriterPrinter) PrintfInstant(format string, args ...interface{}) {
|
||||
fmt.Printf(format, args...)
|
||||
}
|
||||
|
||||
// typewriterPrint displays text with a typewriter animation effect
|
||||
func (tp *TypewriterPrinter) typewriterPrint(text string) {
|
||||
// Convert string to runes to handle Unicode properly
|
||||
runes := []rune(text)
|
||||
|
||||
for i, r := range runes {
|
||||
// Print the character
|
||||
fmt.Print(string(r))
|
||||
os.Stdout.Sync() // Force immediate output
|
||||
|
||||
// Don't add delay after the last character
|
||||
if i == len(runes)-1 {
|
||||
break
|
||||
}
|
||||
|
||||
// Determine delay based on character type
|
||||
delay := tp.getDelayForCharacter(r, i)
|
||||
|
||||
// Sleep for the calculated delay
|
||||
time.Sleep(delay)
|
||||
}
|
||||
}
|
||||
|
||||
// getDelayForCharacter returns the appropriate delay for a character
|
||||
func (tp *TypewriterPrinter) getDelayForCharacter(r rune, position int) time.Duration {
|
||||
var baseDelay time.Duration
|
||||
|
||||
switch {
|
||||
case r == '.' || r == '!' || r == '?':
|
||||
// Longer pause after sentence endings
|
||||
baseDelay = tp.config.PauseDelay
|
||||
case r == ',' || r == ';' || r == ':':
|
||||
// Medium pause after punctuation
|
||||
baseDelay = tp.config.SlowDelay
|
||||
case r == ' ':
|
||||
// Slightly faster for spaces
|
||||
baseDelay = tp.config.FastDelay
|
||||
case r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z':
|
||||
// Fast for common letters
|
||||
baseDelay = tp.config.FastDelay
|
||||
case r == '\n':
|
||||
// No delay for newlines
|
||||
return 0
|
||||
default:
|
||||
// Base delay for other characters
|
||||
baseDelay = tp.config.BaseDelay
|
||||
}
|
||||
|
||||
// Add randomness to make it feel more natural
|
||||
if tp.config.RandomFactor > 0 {
|
||||
// Simple pseudo-random based on position to ensure consistency
|
||||
randomFactor := 0.7 + (tp.config.RandomFactor * float64(position%7) / 6.0)
|
||||
baseDelay = time.Duration(float64(baseDelay) * randomFactor)
|
||||
}
|
||||
|
||||
return baseDelay
|
||||
}
|
||||
|
||||
// SetEnabled enables or disables the typewriter effect
|
||||
func (tp *TypewriterPrinter) SetEnabled(enabled bool) {
|
||||
tp.config.Enabled = enabled
|
||||
}
|
||||
|
||||
// IsEnabled returns whether the typewriter effect is enabled
|
||||
func (tp *TypewriterPrinter) IsEnabled() bool {
|
||||
return tp.config.Enabled
|
||||
}
|
||||
|
||||
// SetSpeed adjusts the typewriter speed (multiplier: 0.1 = very slow, 1.0 = normal, 2.0 = fast)
|
||||
func (tp *TypewriterPrinter) SetSpeed(multiplier float64) {
|
||||
if multiplier <= 0 {
|
||||
multiplier = 1.0
|
||||
}
|
||||
|
||||
tp.config.BaseDelay = time.Duration(float64(15*time.Millisecond) / multiplier)
|
||||
tp.config.FastDelay = time.Duration(float64(8*time.Millisecond) / multiplier)
|
||||
tp.config.SlowDelay = time.Duration(float64(25*time.Millisecond) / multiplier)
|
||||
tp.config.PauseDelay = time.Duration(float64(150*time.Millisecond) / multiplier)
|
||||
}
|
||||
|
||||
// PrintMessageLine prints a complete message line with typewriter effect
|
||||
func (tp *TypewriterPrinter) PrintMessageLine(timestamp, prefix, text string) {
|
||||
// Print the timestamp and prefix with 10-char padding
|
||||
tp.PrintfInstant("[%s] %-10s: ", timestamp, prefix)
|
||||
// Print the message text with typewriter effect
|
||||
tp.Println(text)
|
||||
}
|
||||
|
||||
// Global typewriter printer instance
|
||||
var globalTypewriter = NewTypewriterPrinter(DefaultTypewriterConfig())
|
||||
|
||||
// Global convenience functions that use the global typewriter instance
|
||||
|
||||
// TypewriterPrint prints text with typewriter effect using the global instance
|
||||
func TypewriterPrint(text string) {
|
||||
globalTypewriter.Print(text)
|
||||
}
|
||||
|
||||
// TypewriterPrintf prints formatted text with typewriter effect using the global instance
|
||||
func TypewriterPrintf(format string, args ...interface{}) {
|
||||
globalTypewriter.Printf(format, args...)
|
||||
}
|
||||
|
||||
// TypewriterPrintln prints text with typewriter effect and newline using the global instance
|
||||
func TypewriterPrintln(text string) {
|
||||
globalTypewriter.Println(text)
|
||||
}
|
||||
|
||||
// TypewriterPrintfLn prints formatted text with typewriter effect and newline using the global instance
|
||||
func TypewriterPrintfLn(format string, args ...interface{}) {
|
||||
globalTypewriter.PrintfLn(format, args...)
|
||||
}
|
||||
|
||||
// TypewriterPrintMessageLine prints a message line with typewriter effect using the global instance
|
||||
func TypewriterPrintMessageLine(timestamp, prefix, text string) {
|
||||
globalTypewriter.PrintMessageLine(timestamp, prefix, text)
|
||||
}
|
||||
|
||||
// SetGlobalTypewriterEnabled enables or disables the global typewriter effect
|
||||
func SetGlobalTypewriterEnabled(enabled bool) {
|
||||
globalTypewriter.SetEnabled(enabled)
|
||||
}
|
||||
|
||||
// SetGlobalTypewriterSpeed sets the speed of the global typewriter effect
|
||||
func SetGlobalTypewriterSpeed(multiplier float64) {
|
||||
globalTypewriter.SetSpeed(multiplier)
|
||||
}
|
||||
|
||||
// GetGlobalTypewriter returns the global typewriter instance
|
||||
func GetGlobalTypewriter() *TypewriterPrinter {
|
||||
return globalTypewriter
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
package global
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/common"
|
||||
)
|
||||
|
||||
// ClineClients manages Cline instances using the new registry system
|
||||
type ClineClients struct {
|
||||
registry *ClientRegistry
|
||||
}
|
||||
|
||||
// NewClineClients creates a new ClineClients instance
|
||||
func NewClineClients(configPath string) *ClineClients {
|
||||
registry := NewClientRegistry(configPath)
|
||||
return &ClineClients{
|
||||
registry: registry,
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize performs cleanup of stale instances
|
||||
func (c *ClineClients) Initialize(ctx context.Context) error {
|
||||
// Clean up stale entries (direct SQLite operations)
|
||||
_ = c.registry.CleanupStaleInstances(ctx)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartNewInstance starts a new Cline instance and waits for cline-core to self-register
|
||||
func (c *ClineClients) StartNewInstance(ctx context.Context) (*common.CoreInstanceInfo, error) {
|
||||
// Find available ports
|
||||
corePort, hostPort, err := common.FindAvailablePortPair()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to find available ports: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Starting new Cline instance on ports %d (core) and %d (host bridge)\n", corePort, hostPort)
|
||||
|
||||
// Start cline-host first
|
||||
hostCmd, err := startClineHost(hostPort, corePort)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to start cline-host: %w", err)
|
||||
}
|
||||
|
||||
// Start cline-core (it will register itself in SQLite locks database)
|
||||
coreCmd, err := startClineCore(corePort, hostPort)
|
||||
if err != nil {
|
||||
// Clean up host process if core fails to start
|
||||
if hostCmd != nil && hostCmd.Process != nil {
|
||||
hostCmd.Process.Kill()
|
||||
}
|
||||
return nil, fmt.Errorf("failed to start cline-core: %w", err)
|
||||
}
|
||||
|
||||
fullAddress := fmt.Sprintf("localhost:%d", corePort)
|
||||
fmt.Println("Waiting for services to start and self-register in SQLite...")
|
||||
|
||||
// Use RetryOperation to wait for instance to be ready
|
||||
var instance *common.CoreInstanceInfo
|
||||
err = common.RetryOperation(12, 5*time.Second, func() error {
|
||||
// Check if instance registered itself in SQLite
|
||||
foundInstance, err := c.registry.GetInstance(fullAddress)
|
||||
if err != nil || foundInstance == nil {
|
||||
return fmt.Errorf("instance not found in registry: %v", err)
|
||||
}
|
||||
|
||||
// Verify instance is healthy
|
||||
if !common.IsInstanceHealthy(ctx, fullAddress) {
|
||||
return fmt.Errorf("instance is registered but not healthy")
|
||||
}
|
||||
|
||||
// Success - store the instance for return
|
||||
instance = foundInstance
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
// Clean up both processes on failure
|
||||
if coreCmd != nil && coreCmd.Process != nil {
|
||||
fmt.Printf("Cleaning up core process (PID: %d)\n", coreCmd.Process.Pid)
|
||||
coreCmd.Process.Kill()
|
||||
}
|
||||
if hostCmd != nil && hostCmd.Process != nil {
|
||||
fmt.Printf("Cleaning up host process (PID: %d)\n", hostCmd.Process.Pid)
|
||||
hostCmd.Process.Kill()
|
||||
}
|
||||
return nil, fmt.Errorf("failed to start instance: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("✅ Services started and registered successfully!")
|
||||
fmt.Printf(" Address: %s\n", instance.Address)
|
||||
fmt.Printf(" Core Port: %d\n", instance.CorePort())
|
||||
fmt.Printf(" Host Bridge Port: %d\n", instance.HostPort())
|
||||
fmt.Printf(" Process PID: %d\n", coreCmd.Process.Pid)
|
||||
return instance, nil
|
||||
}
|
||||
|
||||
// StartNewInstanceAtPort starts a new Cline instance at the specified port and waits for self-registration
|
||||
func (c *ClineClients) StartNewInstanceAtPort(ctx context.Context, corePort int) (*common.CoreInstanceInfo, error) {
|
||||
// Find available host port (core port + 1000)
|
||||
hostPort := corePort + 1000
|
||||
coreAddress := fmt.Sprintf("localhost:%d", corePort)
|
||||
|
||||
// Check if the specified core port is available
|
||||
if common.IsInstanceHealthy(ctx, coreAddress) {
|
||||
return nil, fmt.Errorf("port %d is already in use by another Cline instance", corePort)
|
||||
}
|
||||
|
||||
fmt.Printf("Starting new Cline instance on ports %d (core) and %d (host bridge)\n", corePort, hostPort)
|
||||
|
||||
// Start cline-host first
|
||||
hostCmd, err := startClineHost(hostPort, corePort)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to start cline-host: %w", err)
|
||||
}
|
||||
|
||||
// Start cline-core (it will register itself in SQLite locks database)
|
||||
coreCmd, err := startClineCore(corePort, hostPort)
|
||||
if err != nil {
|
||||
// Clean up host process if core fails to start
|
||||
if hostCmd != nil && hostCmd.Process != nil {
|
||||
hostCmd.Process.Kill()
|
||||
}
|
||||
return nil, fmt.Errorf("failed to start cline-core: %w", err)
|
||||
}
|
||||
|
||||
fullAddress := fmt.Sprintf("localhost:%d", corePort)
|
||||
fmt.Println("Waiting for services to start and self-register in SQLite...")
|
||||
|
||||
// Use RetryOperation to wait for instance to be ready
|
||||
var instance *common.CoreInstanceInfo
|
||||
err = common.RetryOperation(12, 5*time.Second, func() error {
|
||||
// Check if instance registered itself in SQLite
|
||||
foundInstance, err := c.registry.GetInstance(fullAddress)
|
||||
if err != nil || foundInstance == nil {
|
||||
return fmt.Errorf("instance not found in registry: %v", err)
|
||||
}
|
||||
|
||||
// Verify instance is healthy
|
||||
if !common.IsInstanceHealthy(ctx, fullAddress) {
|
||||
return fmt.Errorf("instance is registered but not healthy")
|
||||
}
|
||||
|
||||
// Success - store the instance for return
|
||||
instance = foundInstance
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
// Clean up both processes on failure
|
||||
if coreCmd != nil && coreCmd.Process != nil {
|
||||
fmt.Printf("Cleaning up core process (PID: %d)\n", coreCmd.Process.Pid)
|
||||
coreCmd.Process.Kill()
|
||||
}
|
||||
if hostCmd != nil && hostCmd.Process != nil {
|
||||
fmt.Printf("Cleaning up host process (PID: %d)\n", hostCmd.Process.Pid)
|
||||
hostCmd.Process.Kill()
|
||||
}
|
||||
return nil, fmt.Errorf("failed to start instance at port %d: %w", corePort, err)
|
||||
}
|
||||
|
||||
fmt.Println("✅ Services started and registered successfully!")
|
||||
fmt.Printf(" Address: %s\n", instance.Address)
|
||||
fmt.Printf(" Core Port: %d\n", instance.CorePort())
|
||||
fmt.Printf(" Host Bridge Port: %d\n", instance.HostPort())
|
||||
fmt.Printf(" Process PID: %d\n", coreCmd.Process.Pid)
|
||||
return instance, nil
|
||||
}
|
||||
|
||||
// GetRegistry returns the client registry
|
||||
func (c *ClineClients) GetRegistry() *ClientRegistry {
|
||||
return c.registry
|
||||
}
|
||||
|
||||
// EnsureInstanceAtAddress ensures an instance exists at the given address, starting one if needed
|
||||
func (c *ClineClients) EnsureInstanceAtAddress(ctx context.Context, address string) error {
|
||||
// Expect host:port everywhere
|
||||
normalized := address
|
||||
if normalized == "" {
|
||||
normalized = fmt.Sprintf("localhost:%d", common.DEFAULT_CLINE_CORE_PORT)
|
||||
}
|
||||
|
||||
// Check if instance already exists at this address
|
||||
if c.registry.HasInstanceAtAddress(normalized) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse host:port
|
||||
host, port, err := common.ParseHostPort(normalized)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid address format %s", address)
|
||||
}
|
||||
|
||||
// Use IPv6-compatible localhost detection
|
||||
if common.IsLocalAddress(host) {
|
||||
_, err := c.StartNewInstanceAtPort(ctx, port)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start new instance at %s: %w", normalized, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot start remote instance at %s", normalized)
|
||||
}
|
||||
|
||||
func startClineHost(hostPort, corePort int) (*exec.Cmd, error) {
|
||||
fmt.Printf("Starting cline-host on port %d\n", hostPort)
|
||||
|
||||
// Start the cline-host process
|
||||
cmd := exec.Command("./cli/bin/cline-host",
|
||||
"--verbose",
|
||||
"--port", fmt.Sprintf("%d", hostPort))
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("failed to start cline-host: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Started cline-host (PID: %d)\n", cmd.Process.Pid)
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
func startClineCore(corePort, hostPort int) (*exec.Cmd, error) {
|
||||
fmt.Printf("Starting cline-core on port %d (with hostbridge on %d)\n", corePort, hostPort)
|
||||
|
||||
// Create port-tagged log file in OS temp directory with full address
|
||||
logFileName := fmt.Sprintf("cline-core-debug-localhost-%d.log", corePort)
|
||||
logFilePath := fmt.Sprintf("%s/%s", os.TempDir(), logFileName)
|
||||
logFile, err := os.Create(logFilePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create log file: %w", err)
|
||||
}
|
||||
|
||||
// Start the cline-core process with --config flag instead of CLINE_DIR env var
|
||||
args := []string{"cline-core.js",
|
||||
"--port", fmt.Sprintf("%d", corePort),
|
||||
"--host-bridge-port", fmt.Sprintf("%d", hostPort),
|
||||
"--config", Config.ConfigPath}
|
||||
|
||||
fmt.Printf("DEBUG: Starting cline-core with command: node %v\n", args)
|
||||
fmt.Printf("DEBUG: Working directory: ./dist-standalone\n")
|
||||
fmt.Printf("DEBUG: Config path: %s\n", Config.ConfigPath)
|
||||
|
||||
cmd := exec.Command("node", args...)
|
||||
|
||||
// Set working directory to dist-standalone (relative to project root)
|
||||
cmd.Dir = "./dist-standalone"
|
||||
|
||||
// Redirect stdout and stderr to log file
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
|
||||
// Set environment variables (removed CLINE_DIR)
|
||||
env := os.Environ()
|
||||
env = append(env,
|
||||
"GRPC_TRACE=all",
|
||||
"GRPC_VERBOSITY=DEBUG",
|
||||
"NODE_ENV=development",
|
||||
)
|
||||
cmd.Env = env
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
logFile.Close()
|
||||
return nil, fmt.Errorf("failed to start cline-core: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Started cline-core (PID: %d)\n", cmd.Process.Pid)
|
||||
fmt.Printf("Logging cline-core output to: %s\n", logFilePath)
|
||||
return cmd, nil
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
package global
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/cline/cli/pkg/common"
|
||||
"github.com/cline/grpc-go/client"
|
||||
)
|
||||
|
||||
type Port uint16
|
||||
|
||||
type GlobalConfig struct {
|
||||
ConfigPath string
|
||||
Verbose bool
|
||||
OutputFormat string
|
||||
CoreAddress string
|
||||
}
|
||||
|
||||
var (
|
||||
Config *GlobalConfig
|
||||
Clients *ClineClients
|
||||
)
|
||||
|
||||
func InitializeGlobalConfig(cfg *GlobalConfig) error {
|
||||
if cfg.ConfigPath == "" {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get home directory: %w", err)
|
||||
}
|
||||
cfg.ConfigPath = filepath.Join(homeDir, ".cline")
|
||||
}
|
||||
|
||||
// Ensure .cline directory exists
|
||||
if err := os.MkdirAll(cfg.ConfigPath, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create config directory: %w", err)
|
||||
}
|
||||
|
||||
Config = cfg
|
||||
Clients = NewClineClients(cfg.ConfigPath)
|
||||
|
||||
// Initialize the clients registry
|
||||
ctx := context.Background()
|
||||
if err := Clients.Initialize(ctx); err != nil {
|
||||
return fmt.Errorf("failed to initialize clients: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDefaultClient returns a client for the default instance or the address override
|
||||
func GetDefaultClient(ctx context.Context) (*client.ClineClient, error) {
|
||||
if Config.CoreAddress != "" && Config.CoreAddress != fmt.Sprintf("localhost:%d", common.DEFAULT_CLINE_CORE_PORT) {
|
||||
// User specified a specific address, use that
|
||||
return Clients.GetRegistry().GetClient(ctx, Config.CoreAddress)
|
||||
}
|
||||
|
||||
// Use the default instance from registry
|
||||
return Clients.GetRegistry().GetDefaultClient(ctx)
|
||||
}
|
||||
|
||||
// GetClientForAddress returns a client for a specific address
|
||||
func GetClientForAddress(ctx context.Context, address string) (*client.ClineClient, error) {
|
||||
return Clients.GetRegistry().GetClient(ctx, address)
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
package global
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/sqlite"
|
||||
"github.com/cline/cli/pkg/common"
|
||||
"github.com/cline/grpc-go/client"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
"github.com/cline/grpc-go/host"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/health/grpc_health_v1"
|
||||
)
|
||||
|
||||
// ClientRegistry manages Cline client connections using direct SQLite operations
|
||||
type ClientRegistry struct {
|
||||
lockManager *sqlite.LockManager
|
||||
configPath string
|
||||
}
|
||||
|
||||
// NewClientRegistry creates a new client registry
|
||||
func NewClientRegistry(configPath string) *ClientRegistry {
|
||||
lockManager, err := sqlite.NewLockManager(configPath)
|
||||
if err != nil {
|
||||
// Log error but continue - we can still function without SQLite
|
||||
log.Fatalf("Warning: Failed to initialize SQLite lock manager: %v\n", err)
|
||||
}
|
||||
|
||||
return &ClientRegistry{
|
||||
lockManager: lockManager,
|
||||
configPath: configPath,
|
||||
}
|
||||
}
|
||||
|
||||
// GetDefaultInstance returns the default instance address from settings file
|
||||
func (r *ClientRegistry) GetDefaultInstance() string {
|
||||
defaultAddr, err := sqlite.GetDefaultInstance(r.configPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return defaultAddr
|
||||
}
|
||||
|
||||
// SetDefaultInstance sets the default instance (writes default.json)
|
||||
func (r *ClientRegistry) SetDefaultInstance(address string) error {
|
||||
// Verify the instance exists in SQLite
|
||||
if r.lockManager != nil {
|
||||
exists, err := r.lockManager.HasInstanceAtAddress(address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check instance existence: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("instance %s not found in registry", address)
|
||||
}
|
||||
}
|
||||
|
||||
return sqlite.SetDefaultInstance(r.configPath, address)
|
||||
}
|
||||
|
||||
// GetInstance returns instance information directly from SQLite
|
||||
func (r *ClientRegistry) GetInstance(address string) (*common.CoreInstanceInfo, error) {
|
||||
if r.lockManager == nil {
|
||||
return nil, fmt.Errorf("lock manager not available")
|
||||
}
|
||||
|
||||
return r.lockManager.GetInstanceInfo(address)
|
||||
}
|
||||
|
||||
// GetClient returns a connected client for the given address (created on-demand)
|
||||
func (r *ClientRegistry) GetClient(ctx context.Context, address string) (*client.ClineClient, error) {
|
||||
// Verify instance exists in SQLite
|
||||
if r.lockManager != nil {
|
||||
exists, err := r.lockManager.HasInstanceAtAddress(address)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check instance existence: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("instance %s not found", address)
|
||||
}
|
||||
}
|
||||
|
||||
// Create client on-demand (no caching)
|
||||
target, err := common.NormalizeAddressForGRPC(address)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid address %s: %w", address, err)
|
||||
}
|
||||
|
||||
cl, err := client.NewClineClient(target)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create client for %s: %w", target, err)
|
||||
}
|
||||
|
||||
if err := cl.Connect(ctx); err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to %s: %w", target, err)
|
||||
}
|
||||
|
||||
return cl, nil
|
||||
}
|
||||
|
||||
// GetDefaultClient returns a client for the default instance
|
||||
func (r *ClientRegistry) GetDefaultClient(ctx context.Context) (*client.ClineClient, error) {
|
||||
defaultAddr := r.GetDefaultInstance()
|
||||
if defaultAddr == "" {
|
||||
return nil, fmt.Errorf("no default instance configured")
|
||||
}
|
||||
|
||||
return r.GetClient(ctx, defaultAddr)
|
||||
}
|
||||
|
||||
// ListInstances returns all registered instances directly from SQLite
|
||||
func (r *ClientRegistry) ListInstances() []*common.CoreInstanceInfo {
|
||||
if r.lockManager == nil {
|
||||
return []*common.CoreInstanceInfo{}
|
||||
}
|
||||
|
||||
// Use context with timeout for health checks
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
instances, err := r.lockManager.ListInstancesWithHealthCheck(ctx)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: Failed to list instances: %v\n", err)
|
||||
return []*common.CoreInstanceInfo{}
|
||||
}
|
||||
|
||||
return instances
|
||||
}
|
||||
|
||||
// HasInstanceAtAddress checks if an instance exists at the given address (delegates to SQLite)
|
||||
func (r *ClientRegistry) HasInstanceAtAddress(address string) bool {
|
||||
if r.lockManager == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
exists, err := r.lockManager.HasInstanceAtAddress(address)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: Failed to check instance existence: %v\n", err)
|
||||
return false
|
||||
}
|
||||
|
||||
return exists
|
||||
}
|
||||
|
||||
// CleanupStaleInstances removes stale instances using direct SQLite operations
|
||||
func (r *ClientRegistry) CleanupStaleInstances(ctx context.Context) error {
|
||||
if r.lockManager == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get all instances with health checks
|
||||
instances, err := r.lockManager.ListInstancesWithHealthCheck(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list instances for cleanup: %w", err)
|
||||
}
|
||||
|
||||
// Clean up all stale instances
|
||||
for _, instance := range instances {
|
||||
if instance.Status != grpc_health_v1.HealthCheckResponse_SERVING {
|
||||
// Try to gracefully shutdown the paired host process before cleanup
|
||||
|
||||
fmt.Printf("Attempting to shutdown dangling host service %s for stale cline core instance %s\n",
|
||||
instance.HostServiceAddress, instance.Address)
|
||||
r.tryShutdownHostProcess(instance.HostServiceAddress)
|
||||
|
||||
// Remove from SQLite database
|
||||
if err := r.lockManager.RemoveInstanceLock(instance.Address); err != nil {
|
||||
return fmt.Errorf("failed to remove stale instance %s: %w", instance.Address, err)
|
||||
}
|
||||
|
||||
fmt.Printf("Removed stale instance: %s\n", instance.Address)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// tryShutdownHostProcess attempts to gracefully shutdown a host process via RPC
|
||||
// Best effort, don't throw errors i guess
|
||||
func (r *ClientRegistry) tryShutdownHostProcess(hostServiceAddress string) {
|
||||
err := common.RetryOperation(3, 2*time.Second, func() error {
|
||||
// Create context with timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Create gRPC connection to host bridge
|
||||
conn, err := grpc.DialContext(ctx, hostServiceAddress,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpc.WithBlock())
|
||||
if err != nil {
|
||||
return fmt.Errorf("connection failed: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Create env service client and call shutdown
|
||||
envClient := host.NewEnvServiceClient(conn)
|
||||
_, err = envClient.Shutdown(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("RPC failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: Failed to request host bridge shutdown on port %s: %v\n", hostServiceAddress, err)
|
||||
} else {
|
||||
fmt.Printf("Host bridge shutdown requested successfully on port %s\n", hostServiceAddress)
|
||||
}
|
||||
}
|
||||
|
||||
// ListInstancesCleaned performs cleanup and returns instances with health checks
|
||||
func (r *ClientRegistry) ListInstancesCleaned(ctx context.Context) ([]*common.CoreInstanceInfo, error) {
|
||||
// 1. Clean up stale entries (best-effort)
|
||||
_ = r.CleanupStaleInstances(ctx)
|
||||
|
||||
// 2. Get all instances with real-time health checks
|
||||
instances := r.ListInstances()
|
||||
|
||||
// 3. Ensure default is set if instances exist
|
||||
if err := r.ensureDefaultInstance(instances); err != nil {
|
||||
fmt.Printf("Warning: Failed to ensure default instance: %v\n", err)
|
||||
}
|
||||
|
||||
return instances, nil
|
||||
}
|
||||
|
||||
// ensureDefaultInstance ensures a default instance is set if instances exist but no default is configured
|
||||
func (r *ClientRegistry) ensureDefaultInstance(instances []*common.CoreInstanceInfo) error {
|
||||
currentDefault := r.GetDefaultInstance()
|
||||
|
||||
// If we have no instances, clear any stale default and remove settings file
|
||||
if len(instances) == 0 {
|
||||
if currentDefault != "" {
|
||||
// Remove the settings file since no instances exist
|
||||
settingsPath := filepath.Join(r.configPath, common.SETTINGS_SUBFOLDER, "settings", "cli-default-instance.json")
|
||||
_ = os.Remove(settingsPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// If we have instances but no default, pick the first one
|
||||
if currentDefault == "" {
|
||||
return sqlite.SetDefaultInstance(r.configPath, instances[0].Address)
|
||||
}
|
||||
|
||||
// Validate current default still exists in the instances
|
||||
defaultExists := false
|
||||
for _, instance := range instances {
|
||||
if instance.Address == currentDefault {
|
||||
defaultExists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !defaultExists {
|
||||
// Current default doesn't exist, pick a new one from available instances
|
||||
return sqlite.SetDefaultInstance(r.configPath, instances[0].Address)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,354 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
)
|
||||
|
||||
// AskHandler handles ASK type messages
|
||||
type AskHandler struct {
|
||||
*BaseHandler
|
||||
}
|
||||
|
||||
// NewAskHandler creates a new ASK handler
|
||||
func NewAskHandler() *AskHandler {
|
||||
return &AskHandler{
|
||||
BaseHandler: NewBaseHandler("ask", PriorityHigh),
|
||||
}
|
||||
}
|
||||
|
||||
// CanHandle returns true if this is an ASK message
|
||||
func (h *AskHandler) CanHandle(msg *types.ClineMessage) bool {
|
||||
return msg.IsAsk()
|
||||
}
|
||||
|
||||
// Handle processes ASK messages
|
||||
func (h *AskHandler) Handle(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
timestamp := msg.GetTimestamp()
|
||||
|
||||
switch msg.Ask {
|
||||
case string(types.AskTypeFollowup):
|
||||
return h.handleFollowup(msg, dc, timestamp)
|
||||
case string(types.AskTypePlanModeRespond):
|
||||
return h.handlePlanModeRespond(msg, dc, timestamp)
|
||||
case string(types.AskTypeCommand):
|
||||
return h.handleCommand(msg, dc, timestamp)
|
||||
case string(types.AskTypeCommandOutput):
|
||||
return h.handleCommandOutput(msg, dc, timestamp)
|
||||
case string(types.AskTypeCompletionResult):
|
||||
return h.handleCompletionResult(msg, dc, timestamp)
|
||||
case string(types.AskTypeTool):
|
||||
return h.handleTool(msg, dc, timestamp)
|
||||
case string(types.AskTypeAPIReqFailed):
|
||||
return h.handleAPIReqFailed(msg, dc, timestamp)
|
||||
case string(types.AskTypeResumeTask):
|
||||
return h.handleResumeTask(msg, dc, timestamp)
|
||||
case string(types.AskTypeResumeCompletedTask):
|
||||
return h.handleResumeCompletedTask(msg, dc, timestamp)
|
||||
case string(types.AskTypeMistakeLimitReached):
|
||||
return h.handleMistakeLimitReached(msg, dc, timestamp)
|
||||
case string(types.AskTypeAutoApprovalMaxReached):
|
||||
return h.handleAutoApprovalMaxReached(msg, dc, timestamp)
|
||||
case string(types.AskTypeBrowserActionLaunch):
|
||||
return h.handleBrowserActionLaunch(msg, dc, timestamp)
|
||||
case string(types.AskTypeUseMcpServer):
|
||||
return h.handleUseMcpServer(msg, dc, timestamp)
|
||||
case string(types.AskTypeNewTask):
|
||||
return h.handleNewTask(msg, dc, timestamp)
|
||||
case string(types.AskTypeCondense):
|
||||
return h.handleCondense(msg, dc, timestamp)
|
||||
case string(types.AskTypeReportBug):
|
||||
return h.handleReportBug(msg, dc, timestamp)
|
||||
default:
|
||||
return h.handleDefault(msg, dc, timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
// handleFollowup handles followup questions
|
||||
func (h *AskHandler) handleFollowup(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
var question string
|
||||
var options []string
|
||||
|
||||
var askData types.AskData
|
||||
if err := json.Unmarshal([]byte(msg.Text), &askData); err == nil {
|
||||
question = askData.Question
|
||||
options = askData.Options
|
||||
} else {
|
||||
question = msg.Text
|
||||
}
|
||||
|
||||
if question == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := dc.Renderer.RenderMessage(timestamp, "QUESTION", question)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Display options if available
|
||||
if len(options) > 0 {
|
||||
fmt.Println("\nOptions:")
|
||||
for i, option := range options {
|
||||
fmt.Printf("%d. %s\n", i+1, option)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handlePlanModeRespond handles plan mode responses
|
||||
func (h *AskHandler) handlePlanModeRespond(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
var response string
|
||||
var options []string
|
||||
|
||||
// Try to parse as JSON
|
||||
type PlanModeResponse struct {
|
||||
Response string `json:"response"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
var planData PlanModeResponse
|
||||
if err := json.Unmarshal([]byte(msg.Text), &planData); err == nil {
|
||||
response = planData.Response
|
||||
options = planData.Options
|
||||
} else {
|
||||
response = msg.Text
|
||||
}
|
||||
|
||||
if response == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := dc.Renderer.RenderMessage(timestamp, "ASST PLAN", response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Display options if available
|
||||
if len(options) > 0 {
|
||||
fmt.Println("\nOptions:")
|
||||
for i, option := range options {
|
||||
fmt.Printf("%d. %s\n", i+1, option)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleCommand handles command execution requests
|
||||
func (h *AskHandler) handleCommand(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
command := msg.Text
|
||||
|
||||
// Check if this command was flagged despite auto-approval settings turned on for safe commands
|
||||
hasAutoApprovalConflict := strings.HasSuffix(command, "REQ_APP")
|
||||
if hasAutoApprovalConflict {
|
||||
command = strings.TrimSuffix(command, "REQ_APP")
|
||||
}
|
||||
|
||||
err := dc.Renderer.RenderMessage(timestamp, "TERMINAL", "Cline wants to execute this command:")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render handleCommand: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\n```shell\n%s\n```\n", strings.TrimSpace(command))
|
||||
|
||||
if hasAutoApprovalConflict {
|
||||
fmt.Printf("\nThe model has determined this command requires explicit approval.\n")
|
||||
} else {
|
||||
fmt.Printf("\nApproval required for this command.\n")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleCommandOutput handles command output requests
|
||||
func (h *AskHandler) handleCommandOutput(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
commandOutput := msg.Text
|
||||
|
||||
err := dc.Renderer.RenderMessage(timestamp, "TERMINAL", fmt.Sprintf("Current terminal output: %s", commandOutput))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render handleCommandOutput: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\nApprove to proceed while this command runs in the background.\n")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleCompletionResult handles completion result requests
|
||||
func (h *AskHandler) handleCompletionResult(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleTool handles tool execution requests
|
||||
func (h *AskHandler) handleTool(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
// Parse tool message
|
||||
var tool types.ToolMessage
|
||||
if err := json.Unmarshal([]byte(msg.Text), &tool); err != nil {
|
||||
// Fallback to simple display
|
||||
return dc.Renderer.RenderMessage(timestamp, "TOOL", msg.Text)
|
||||
}
|
||||
|
||||
return h.renderToolMessage(&tool, dc, timestamp)
|
||||
}
|
||||
|
||||
// renderToolMessage renders a tool message with appropriate formatting
|
||||
func (h *AskHandler) renderToolMessage(tool *types.ToolMessage, dc *DisplayContext, timestamp string) error {
|
||||
switch tool.Tool {
|
||||
case string(types.ToolTypeEditedExistingFile):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to edit file: %s", tool.Path))
|
||||
case string(types.ToolTypeNewFileCreated):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to create file: %s", tool.Path))
|
||||
case string(types.ToolTypeReadFile):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to read file: %s", tool.Path))
|
||||
case string(types.ToolTypeListFilesTopLevel):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to list files in: %s", tool.Path))
|
||||
case string(types.ToolTypeListFilesRecursive):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to recursively list files in: %s", tool.Path))
|
||||
case string(types.ToolTypeSearchFiles):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to search for '%s' in: %s", tool.Regex, tool.Path))
|
||||
case string(types.ToolTypeWebFetch):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to fetch URL: %s", tool.Path))
|
||||
case string(types.ToolTypeListCodeDefinitionNames):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to list code definitions for: %s", tool.Path))
|
||||
default:
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to use tool: %s", tool.Tool))
|
||||
}
|
||||
|
||||
// Skip content preview for readFile and webFetch tools
|
||||
if tool.Tool == string(types.ToolTypeReadFile) || tool.Tool == string(types.ToolTypeWebFetch) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Show content preview, truncating if necessary
|
||||
preview := tool.Content
|
||||
if preview != "" {
|
||||
preview = strings.TrimSpace(tool.Content)
|
||||
if len(preview) > 1000 {
|
||||
preview = preview[:1000] + "..."
|
||||
}
|
||||
|
||||
fmt.Printf("Preview: %s\n", preview)
|
||||
}
|
||||
|
||||
fmt.Printf("\nApproval required.\n")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleAPIReqFailed handles API request failures
|
||||
func (h *AskHandler) handleAPIReqFailed(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "ERROR", fmt.Sprintf("API Request Failed: %s. Approve to retry request.", msg.Text))
|
||||
}
|
||||
|
||||
// handleResumeTask handles resume task requests
|
||||
func (h *AskHandler) handleResumeTask(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "GEN INFO", "Resuming interrupted task.")
|
||||
}
|
||||
|
||||
// handleResumeCompletedTask handles resume completed task requests
|
||||
func (h *AskHandler) handleResumeCompletedTask(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "GEN INFO", "Resuming completed task.")
|
||||
}
|
||||
|
||||
// handleMistakeLimitReached handles mistake limit reached
|
||||
func (h *AskHandler) handleMistakeLimitReached(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "ERROR", fmt.Sprintf("Mistake Limit Reached: %s. Approval required.", msg.Text))
|
||||
}
|
||||
|
||||
// handleAutoApprovalMaxReached handles auto-approval max reached
|
||||
func (h *AskHandler) handleAutoApprovalMaxReached(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "WARNING", fmt.Sprintf("Auto-approval limit reached: %s. Approval required.", msg.Text))
|
||||
}
|
||||
|
||||
// handleBrowserActionLaunch handles browser action launch requests
|
||||
func (h *AskHandler) handleBrowserActionLaunch(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
url := strings.TrimSpace(msg.Text)
|
||||
return dc.Renderer.RenderMessage(timestamp, "BROWSER", fmt.Sprintf("Cline wants to launch browser and navigate to: %s. Approval required.", url))
|
||||
}
|
||||
|
||||
// handleUseMcpServer handles MCP server usage requests
|
||||
func (h *AskHandler) handleUseMcpServer(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
// Parse MCP server usage request
|
||||
type McpServerRequest struct {
|
||||
ServerName string `json:"serverName"`
|
||||
Type string `json:"type"`
|
||||
ToolName string `json:"toolName,omitempty"`
|
||||
Arguments string `json:"arguments,omitempty"`
|
||||
URI string `json:"uri,omitempty"`
|
||||
}
|
||||
|
||||
var mcpReq McpServerRequest
|
||||
if err := json.Unmarshal([]byte(msg.Text), &mcpReq); err != nil {
|
||||
return dc.Renderer.RenderMessage(timestamp, "MCP", msg.Text)
|
||||
}
|
||||
|
||||
var operation string
|
||||
if mcpReq.Type == "access_mcp_resource" {
|
||||
operation = "access a resource"
|
||||
} else {
|
||||
operation = fmt.Sprintf("use a tool (%s)", mcpReq.ToolName)
|
||||
if mcpReq.Arguments != "" {
|
||||
operation = fmt.Sprintf("%s with args (%s)", operation, mcpReq.Arguments)
|
||||
}
|
||||
}
|
||||
|
||||
return dc.Renderer.RenderMessage(timestamp, "MCP",
|
||||
fmt.Sprintf("Cline wants to %s on the %s MCP server", operation, mcpReq.ServerName))
|
||||
}
|
||||
|
||||
// handleNewTask handles new task creation requests
|
||||
func (h *AskHandler) handleNewTask(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "NEW TASK", fmt.Sprintf("Cline wants to start a new task: %s. Approval required.", msg.Text))
|
||||
}
|
||||
|
||||
// handleCondense handles conversation condensing requests
|
||||
func (h *AskHandler) handleCondense(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "CONDENSE", fmt.Sprintf("Cline wants to condense the conversation: %s. Approval required.", msg.Text))
|
||||
}
|
||||
|
||||
// handleReportBug handles bug report requests
|
||||
func (h *AskHandler) handleReportBug(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
var bugData struct {
|
||||
Title string `json:"title"`
|
||||
WhatHappened string `json:"what_happened"`
|
||||
StepsToReproduce string `json:"steps_to_reproduce"`
|
||||
APIRequestOutput string `json:"api_request_output"`
|
||||
AdditionalContext string `json:"additional_context"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(msg.Text), &bugData); err != nil {
|
||||
return dc.Renderer.RenderMessage(timestamp, "BUG REPORT", fmt.Sprintf("Cline wants to create a GitHub issue: %s. Approval required.", msg.Text))
|
||||
}
|
||||
|
||||
err := dc.Renderer.RenderMessage(timestamp, "BUG REPORT", "Cline wants to create a GitHub issue:")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render handleReportBug: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\n**Title**: %s\n", bugData.Title)
|
||||
fmt.Printf("**What Happened**: %s\n", bugData.WhatHappened)
|
||||
fmt.Printf("**Steps to Reproduce**: %s\n", bugData.StepsToReproduce)
|
||||
fmt.Printf("**API Request Output**: %s\n", bugData.APIRequestOutput)
|
||||
fmt.Printf("**Additional Context**: %s\n", bugData.AdditionalContext)
|
||||
fmt.Printf("\nApprove to create a GitHub issue.\n")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleDefault handles unknown ASK message types
|
||||
func (h *AskHandler) handleDefault(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "ASK", msg.Text)
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"github.com/cline/cli/pkg/cli/display"
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
)
|
||||
|
||||
// MessageHandler defines the interface for handling different message types
|
||||
type MessageHandler interface {
|
||||
// CanHandle returns true if this handler can process the given message
|
||||
CanHandle(msg *types.ClineMessage) bool
|
||||
|
||||
// Handle processes the message and renders it using the display context
|
||||
Handle(msg *types.ClineMessage, dc *DisplayContext) error
|
||||
|
||||
// GetPriority returns the priority of this handler (higher = more priority)
|
||||
GetPriority() int
|
||||
|
||||
// GetName returns a human-readable name for this handler
|
||||
GetName() string
|
||||
}
|
||||
|
||||
// DisplayContext provides context and utilities for message handlers
|
||||
type DisplayContext struct {
|
||||
State *types.ConversationState
|
||||
Renderer *display.Renderer
|
||||
IsLast bool
|
||||
IsPartial bool
|
||||
Verbose bool
|
||||
MessageIndex int
|
||||
}
|
||||
|
||||
// BaseHandler provides common functionality for message handlers
|
||||
type BaseHandler struct {
|
||||
name string
|
||||
priority int
|
||||
}
|
||||
|
||||
// NewBaseHandler creates a new base handler
|
||||
func NewBaseHandler(name string, priority int) *BaseHandler {
|
||||
return &BaseHandler{
|
||||
name: name,
|
||||
priority: priority,
|
||||
}
|
||||
}
|
||||
|
||||
// GetName returns the handler name
|
||||
func (h *BaseHandler) GetName() string {
|
||||
return h.name
|
||||
}
|
||||
|
||||
// GetPriority returns the handler priority
|
||||
func (h *BaseHandler) GetPriority() int {
|
||||
return h.priority
|
||||
}
|
||||
|
||||
// HandlerRegistry manages a collection of message handlers
|
||||
type HandlerRegistry struct {
|
||||
handlers []MessageHandler
|
||||
}
|
||||
|
||||
// NewHandlerRegistry creates a new handler registry
|
||||
func NewHandlerRegistry() *HandlerRegistry {
|
||||
return &HandlerRegistry{
|
||||
handlers: make([]MessageHandler, 0),
|
||||
}
|
||||
}
|
||||
|
||||
// Register adds a handler to the registry
|
||||
func (r *HandlerRegistry) Register(handler MessageHandler) {
|
||||
r.handlers = append(r.handlers, handler)
|
||||
|
||||
// Sort handlers by priority (highest first)
|
||||
for i := len(r.handlers) - 1; i > 0; i-- {
|
||||
if r.handlers[i].GetPriority() > r.handlers[i-1].GetPriority() {
|
||||
r.handlers[i], r.handlers[i-1] = r.handlers[i-1], r.handlers[i]
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle finds the appropriate handler and processes the message
|
||||
func (r *HandlerRegistry) Handle(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
for _, handler := range r.handlers {
|
||||
if handler.CanHandle(msg) {
|
||||
return handler.Handle(msg, dc)
|
||||
}
|
||||
}
|
||||
|
||||
// If no specific handler found, use default text handler
|
||||
return r.handleDefault(msg, dc)
|
||||
}
|
||||
|
||||
// handleDefault provides default handling for unrecognized messages
|
||||
func (r *HandlerRegistry) handleDefault(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
timestamp := msg.GetTimestamp()
|
||||
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
prefix := "RESPONSE:"
|
||||
|
||||
return dc.Renderer.RenderMessage(timestamp, prefix, msg.Text)
|
||||
}
|
||||
|
||||
// GetHandlers returns all registered handlers
|
||||
func (r *HandlerRegistry) GetHandlers() []MessageHandler {
|
||||
return r.handlers
|
||||
}
|
||||
|
||||
// GetHandlerByName finds a handler by name
|
||||
func (r *HandlerRegistry) GetHandlerByName(name string) MessageHandler {
|
||||
for _, handler := range r.handlers {
|
||||
if handler.GetName() == name {
|
||||
return handler
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandlerPriorities defines standard priority levels for handlers
|
||||
const (
|
||||
PriorityHigh = 100
|
||||
PriorityNormal = 50
|
||||
PriorityLow = 10
|
||||
)
|
||||
@@ -1,418 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
)
|
||||
|
||||
// SayHandler handles SAY type messages
|
||||
type SayHandler struct {
|
||||
*BaseHandler
|
||||
}
|
||||
|
||||
// NewSayHandler creates a new SAY handler
|
||||
func NewSayHandler() *SayHandler {
|
||||
return &SayHandler{
|
||||
BaseHandler: NewBaseHandler("say", PriorityNormal),
|
||||
}
|
||||
}
|
||||
|
||||
// CanHandle returns true if this is a SAY message
|
||||
func (h *SayHandler) CanHandle(msg *types.ClineMessage) bool {
|
||||
return msg.IsSay()
|
||||
}
|
||||
|
||||
// Handle processes SAY messages
|
||||
func (h *SayHandler) Handle(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
timestamp := msg.GetTimestamp()
|
||||
|
||||
switch msg.Say {
|
||||
case string(types.SayTypeTask):
|
||||
return h.handleTask(msg, dc, timestamp)
|
||||
case string(types.SayTypeError):
|
||||
return h.handleError(msg, dc, timestamp)
|
||||
case string(types.SayTypeAPIReqStarted):
|
||||
return h.handleAPIReqStarted(msg, dc, timestamp)
|
||||
case string(types.SayTypeAPIReqFinished):
|
||||
return h.handleAPIReqFinished(msg, dc, timestamp)
|
||||
case string(types.SayTypeText):
|
||||
return h.handleText(msg, dc, timestamp)
|
||||
case string(types.SayTypeReasoning):
|
||||
return h.handleReasoning(msg, dc, timestamp)
|
||||
case string(types.SayTypeCompletionResult):
|
||||
return h.handleCompletionResult(msg, dc, timestamp)
|
||||
case string(types.SayTypeUserFeedback):
|
||||
return h.handleUserFeedback(msg, dc, timestamp)
|
||||
case string(types.SayTypeUserFeedbackDiff):
|
||||
return h.handleUserFeedbackDiff(msg, dc, timestamp)
|
||||
case string(types.SayTypeAPIReqRetried):
|
||||
return h.handleAPIReqRetried(msg, dc, timestamp)
|
||||
case string(types.SayTypeCommand):
|
||||
return h.handleCommand(msg, dc, timestamp)
|
||||
case string(types.SayTypeCommandOutput):
|
||||
return h.handleCommandOutput(msg, dc, timestamp)
|
||||
case string(types.SayTypeTool):
|
||||
return h.handleTool(msg, dc, timestamp)
|
||||
case string(types.SayTypeShellIntegrationWarning):
|
||||
return h.handleShellIntegrationWarning(msg, dc, timestamp)
|
||||
case string(types.SayTypeBrowserActionLaunch):
|
||||
return h.handleBrowserActionLaunch(msg, dc, timestamp)
|
||||
case string(types.SayTypeBrowserAction):
|
||||
return h.handleBrowserAction(msg, dc, timestamp)
|
||||
case string(types.SayTypeBrowserActionResult):
|
||||
return h.handleBrowserActionResult(msg, dc, timestamp)
|
||||
case string(types.SayTypeMcpServerRequestStarted):
|
||||
return h.handleMcpServerRequestStarted(msg, dc, timestamp)
|
||||
case string(types.SayTypeMcpServerResponse):
|
||||
return h.handleMcpServerResponse(msg, dc, timestamp)
|
||||
case string(types.SayTypeMcpNotification):
|
||||
return h.handleMcpNotification(msg, dc, timestamp)
|
||||
case string(types.SayTypeUseMcpServer):
|
||||
return h.handleUseMcpServer(msg, dc, timestamp)
|
||||
case string(types.SayTypeDiffError):
|
||||
return h.handleDiffError(msg, dc, timestamp)
|
||||
case string(types.SayTypeDeletedAPIReqs):
|
||||
return h.handleDeletedAPIReqs(msg, dc, timestamp)
|
||||
case string(types.SayTypeClineignoreError):
|
||||
return h.handleClineignoreError(msg, dc, timestamp)
|
||||
case string(types.SayTypeCheckpointCreated):
|
||||
return h.handleCheckpointCreated(msg, dc, timestamp)
|
||||
case string(types.SayTypeLoadMcpDocumentation):
|
||||
return h.handleLoadMcpDocumentation(msg, dc, timestamp)
|
||||
case string(types.SayTypeInfo):
|
||||
return h.handleInfo(msg, dc, timestamp)
|
||||
case string(types.SayTypeTaskProgress):
|
||||
return h.handleTaskProgress(msg, dc, timestamp)
|
||||
default:
|
||||
return h.handleDefault(msg, dc, timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
// handleTask handles task messages
|
||||
func (h *SayHandler) handleTask(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleError handles error messages
|
||||
func (h *SayHandler) handleError(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "ERROR", msg.Text)
|
||||
}
|
||||
|
||||
// handleAPIReqStarted handles API request started messages
|
||||
func (h *SayHandler) handleAPIReqStarted(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
// Parse API request info
|
||||
apiInfo := types.APIRequestInfo{Cost: -1}
|
||||
if err := json.Unmarshal([]byte(msg.Text), &apiInfo); err != nil {
|
||||
return dc.Renderer.RenderMessage(timestamp, "API INFO", msg.Text)
|
||||
}
|
||||
|
||||
// Handle different API request states
|
||||
if apiInfo.CancelReason != "" {
|
||||
if apiInfo.CancelReason == "user_cancelled" {
|
||||
return dc.Renderer.RenderMessage(timestamp, "API INFO", "Request Cancelled")
|
||||
} else if apiInfo.CancelReason == "retries_exhausted" {
|
||||
return dc.Renderer.RenderMessage(timestamp, "API INFO", "Request Failed (Retries Exhausted)")
|
||||
}
|
||||
return dc.Renderer.RenderMessage(timestamp, "API INFO", "Streaming Failed")
|
||||
}
|
||||
|
||||
if apiInfo.Cost >= 0 {
|
||||
return dc.Renderer.RenderAPI(timestamp, "Request completed", &apiInfo)
|
||||
}
|
||||
|
||||
// Check for retry status
|
||||
if apiInfo.RetryStatus != nil {
|
||||
return dc.Renderer.RenderRetry(timestamp,
|
||||
apiInfo.RetryStatus.Attempt,
|
||||
apiInfo.RetryStatus.MaxAttempts,
|
||||
apiInfo.RetryStatus.DelaySec)
|
||||
}
|
||||
|
||||
return dc.Renderer.RenderAPI(timestamp, "Processing request", &apiInfo)
|
||||
}
|
||||
|
||||
// handleAPIReqFinished handles API request finished messages
|
||||
func (h *SayHandler) handleAPIReqFinished(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
// This message type is typically not displayed as it's handled by the started message
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleText handles regular text messages
|
||||
func (h *SayHandler) handleText(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Special case for the user's task input
|
||||
prefix := "ASST TEXT"
|
||||
if dc.MessageIndex == 0 {
|
||||
prefix = "USER"
|
||||
}
|
||||
|
||||
return dc.Renderer.RenderMessage(timestamp, prefix, msg.Text)
|
||||
}
|
||||
|
||||
// handleReasoning handles reasoning messages
|
||||
func (h *SayHandler) handleReasoning(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return dc.Renderer.RenderMessage(timestamp, "THINKING", msg.Text)
|
||||
}
|
||||
|
||||
func (h *SayHandler) handleCompletionResult(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
text := msg.Text
|
||||
|
||||
if strings.HasSuffix(text, "HAS_CHANGES") {
|
||||
text = strings.TrimSuffix(text, "HAS_CHANGES")
|
||||
}
|
||||
|
||||
return dc.Renderer.RenderMessage(timestamp, "RESULT", text)
|
||||
}
|
||||
|
||||
// handleUserFeedback handles user feedback messages
|
||||
func (h *SayHandler) handleUserFeedback(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
if msg.Text != "" {
|
||||
return dc.Renderer.RenderMessage(timestamp, "USER", msg.Text)
|
||||
} else {
|
||||
return dc.Renderer.RenderMessage(timestamp, "USER", "[Provided feedback without text]")
|
||||
}
|
||||
}
|
||||
|
||||
// handleUserFeedbackDiff handles user feedback diff messages
|
||||
func (h *SayHandler) handleUserFeedbackDiff(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
var toolMsg types.ToolMessage
|
||||
if err := json.Unmarshal([]byte(msg.Text), &toolMsg); err != nil {
|
||||
return dc.Renderer.RenderMessage(timestamp, "USER DIFF", msg.Text)
|
||||
}
|
||||
|
||||
message := fmt.Sprintf("User manually edited: %s\n\nDiff:\n%s",
|
||||
toolMsg.Path,
|
||||
toolMsg.Diff)
|
||||
|
||||
return dc.Renderer.RenderMessage(timestamp, "USER DIFF", message)
|
||||
}
|
||||
|
||||
// handleAPIReqRetried handles API request retry messages
|
||||
func (h *SayHandler) handleAPIReqRetried(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "API INFO", "Retrying request")
|
||||
}
|
||||
|
||||
// handleCommand handles command execution announcements
|
||||
func (h *SayHandler) handleCommand(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
command := strings.TrimSpace(msg.Text)
|
||||
|
||||
err := dc.Renderer.RenderMessage(timestamp, "TERMINAL", "Running command:")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render handleCommand: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\n```shell\n%s\n```\n", command)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleCommandOutput handles command output messages
|
||||
func (h *SayHandler) handleCommandOutput(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
commandOutput := msg.Text
|
||||
return dc.Renderer.RenderMessage(timestamp, "TERMINAL", fmt.Sprintf("Current terminal output: %s", commandOutput))
|
||||
}
|
||||
|
||||
func (h *SayHandler) handleTool(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
var tool types.ToolMessage
|
||||
if err := json.Unmarshal([]byte(msg.Text), &tool); err != nil {
|
||||
return dc.Renderer.RenderMessage(timestamp, "TOOL", msg.Text)
|
||||
}
|
||||
|
||||
return h.renderToolMessage(&tool, dc, timestamp)
|
||||
}
|
||||
|
||||
func (h *SayHandler) renderToolMessage(tool *types.ToolMessage, dc *DisplayContext, timestamp string) error {
|
||||
switch tool.Tool {
|
||||
case string(types.ToolTypeEditedExistingFile):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline edited file: %s", tool.Path))
|
||||
case string(types.ToolTypeNewFileCreated):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline created file: %s", tool.Path))
|
||||
case string(types.ToolTypeReadFile):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline read file: %s", tool.Path))
|
||||
case string(types.ToolTypeListFilesTopLevel):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline listed files in: %s", tool.Path))
|
||||
case string(types.ToolTypeListFilesRecursive):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline recursively listed files in: %s", tool.Path))
|
||||
case string(types.ToolTypeSearchFiles):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline searched for '%s' in: %s", tool.Regex, tool.Path))
|
||||
case string(types.ToolTypeWebFetch):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline fetched URL: %s", tool.Path))
|
||||
case string(types.ToolTypeListCodeDefinitionNames):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline listed code definitions for: %s", tool.Path))
|
||||
case string(types.ToolTypeSummarizeTask):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", "Cline condensed the conversation")
|
||||
default:
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline executed tool: %s", tool.Tool))
|
||||
}
|
||||
|
||||
// Skip content preview for readFile and webFetch tools
|
||||
if tool.Tool == string(types.ToolTypeReadFile) || tool.Tool == string(types.ToolTypeWebFetch) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Show content preview, truncating if necessary
|
||||
preview := tool.Content
|
||||
if preview != "" {
|
||||
preview = strings.TrimSpace(tool.Content)
|
||||
if len(preview) > 1000 {
|
||||
preview = preview[:1000] + "..."
|
||||
}
|
||||
fmt.Printf("Content: %s\n", preview)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleShellIntegrationWarning handles shell integration warning messages
|
||||
func (h *SayHandler) handleShellIntegrationWarning(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "WARNING", "Shell Integration Unavailable - Cline won't be able to view the command's output.")
|
||||
}
|
||||
|
||||
// handleBrowserActionLaunch handles browser action launch messages
|
||||
func (h *SayHandler) handleBrowserActionLaunch(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
url := msg.Text
|
||||
if url == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return dc.Renderer.RenderMessage(timestamp, "BROWSER", fmt.Sprintf("Launching browser at: %s", url))
|
||||
}
|
||||
|
||||
// handleBrowserAction handles browser action messages
|
||||
func (h *SayHandler) handleBrowserAction(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
type BrowserActionData struct {
|
||||
Action string `json:"action"`
|
||||
Coordinate string `json:"coordinate,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
var actionData BrowserActionData
|
||||
if err := json.Unmarshal([]byte(msg.Text), &actionData); err != nil {
|
||||
return dc.Renderer.RenderMessage(timestamp, "BROWSER", msg.Text)
|
||||
}
|
||||
|
||||
// Special handling for type action
|
||||
if actionData.Action == "type" && actionData.Text != "" {
|
||||
actionText := fmt.Sprintf("type '%s'", actionData.Text)
|
||||
return dc.Renderer.RenderMessage(timestamp, "BROWSER", fmt.Sprintf("Next action: %s", actionText))
|
||||
}
|
||||
|
||||
// Special handling for click action
|
||||
if actionData.Action == "click" && actionData.Coordinate != "" {
|
||||
actionText := fmt.Sprintf("click (%s)", actionData.Coordinate)
|
||||
return dc.Renderer.RenderMessage(timestamp, "BROWSER", fmt.Sprintf("Next action: %s", actionText))
|
||||
}
|
||||
|
||||
// Generic handling for all other actions
|
||||
return dc.Renderer.RenderMessage(timestamp, "BROWSER", fmt.Sprintf("Next action: %s", actionData.Action))
|
||||
}
|
||||
|
||||
// handleBrowserActionResult handles browser action result messages
|
||||
func (h *SayHandler) handleBrowserActionResult(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
type BrowserActionResult struct {
|
||||
Screenshot string `json:"screenshot,omitempty"`
|
||||
Logs string `json:"logs,omitempty"`
|
||||
CurrentUrl string `json:"currentUrl,omitempty"`
|
||||
CurrentMousePosition string `json:"currentMousePosition,omitempty"`
|
||||
}
|
||||
|
||||
var result BrowserActionResult
|
||||
if err := json.Unmarshal([]byte(msg.Text), &result); err != nil {
|
||||
return dc.Renderer.RenderMessage(timestamp, "BROWSER", "Action completed")
|
||||
}
|
||||
|
||||
// If we have logs, include them in the message
|
||||
if result.Logs != "" {
|
||||
return dc.Renderer.RenderMessage(timestamp, "BROWSER", fmt.Sprintf("Action completed with logs: '%s'", result.Logs))
|
||||
}
|
||||
|
||||
// Default case
|
||||
return dc.Renderer.RenderMessage(timestamp, "BROWSER", "Action completed")
|
||||
}
|
||||
|
||||
// handleMcpServerRequestStarted handles MCP server request started messages
|
||||
func (h *SayHandler) handleMcpServerRequestStarted(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "MCP", "Sending request to server")
|
||||
}
|
||||
|
||||
// handleMcpServerResponse handles MCP server response messages
|
||||
func (h *SayHandler) handleMcpServerResponse(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "MCP", fmt.Sprintf("Server response: %s", msg.Text))
|
||||
}
|
||||
|
||||
// handleMcpNotification handles MCP notification messages
|
||||
func (h *SayHandler) handleMcpNotification(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "MCP", fmt.Sprintf("Server notification: %s", msg.Text))
|
||||
}
|
||||
|
||||
// handleUseMcpServer handles MCP server usage messages
|
||||
func (h *SayHandler) handleUseMcpServer(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "MCP", "Server operation approved")
|
||||
}
|
||||
|
||||
// handleDiffError handles diff error messages
|
||||
func (h *SayHandler) handleDiffError(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "WARNING", "Diff Edit Failure - The model used an invalid diff edit format or used search patterns that don't match anything in the file.")
|
||||
}
|
||||
|
||||
// handleDeletedAPIReqs handles deleted API requests messages
|
||||
func (h *SayHandler) handleDeletedAPIReqs(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
// This message includes api metrics of deleted messages, which we do not log
|
||||
return dc.Renderer.RenderMessage(timestamp, "GEN INFO", "Checkpoint restored")
|
||||
}
|
||||
|
||||
// handleClineignoreError handles .clineignore error messages
|
||||
func (h *SayHandler) handleClineignoreError(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "WARNING", fmt.Sprintf("Access Denied - Cline tried to access %s which is blocked by the .clineignore file", msg.Text))
|
||||
}
|
||||
|
||||
// handleCheckpointCreated handles checkpoint created messages
|
||||
func (h *SayHandler) handleCheckpointCreated(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "GEN INFO", "Checkpoint created")
|
||||
}
|
||||
|
||||
// handleLoadMcpDocumentation handles load MCP documentation messages
|
||||
func (h *SayHandler) handleLoadMcpDocumentation(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "GEN INFO", "Loading MCP documentation")
|
||||
}
|
||||
|
||||
// handleInfo handles info messages
|
||||
func (h *SayHandler) handleInfo(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleTaskProgress handles task progress messages
|
||||
func (h *SayHandler) handleTaskProgress(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return dc.Renderer.RenderMessage(timestamp, "PROGRESS", fmt.Sprintf("Task Checklist: %s", msg.Text))
|
||||
}
|
||||
|
||||
// handleDefault handles unknown SAY message types
|
||||
func (h *SayHandler) handleDefault(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "SAY", msg.Text)
|
||||
}
|
||||
@@ -1,388 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
"github.com/spf13/cobra"
|
||||
"google.golang.org/grpc/health/grpc_health_v1"
|
||||
)
|
||||
|
||||
func NewInstanceCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "instance",
|
||||
Aliases: []string{"i"},
|
||||
Short: "Manage Cline instances",
|
||||
Long: `List and manage multiple Cline instances similar to kubectl contexts.`,
|
||||
}
|
||||
|
||||
cmd.AddCommand(newInstanceListCommand())
|
||||
cmd.AddCommand(newInstanceUseCommand())
|
||||
cmd.AddCommand(newInstanceNewCommand())
|
||||
cmd.AddCommand(newInstanceKillCommand())
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newInstanceKillCommand() *cobra.Command {
|
||||
var killAll bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "kill <address>",
|
||||
Aliases: []string{"k"},
|
||||
Short: "Kill a Cline instance by address",
|
||||
Long: `Kill a running Cline instance and clean up its registry entry.`,
|
||||
Args: func(cmd *cobra.Command, args []string) error {
|
||||
if killAll && len(args) > 0 {
|
||||
return fmt.Errorf("cannot specify both --all flag and address argument")
|
||||
}
|
||||
if !killAll && len(args) != 1 {
|
||||
return fmt.Errorf("requires exactly one address argument when --all is not specified")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if global.Clients == nil {
|
||||
return fmt.Errorf("clients not initialized")
|
||||
}
|
||||
|
||||
ctx := cmd.Context()
|
||||
registry := global.Clients.GetRegistry()
|
||||
|
||||
if killAll {
|
||||
return killAllInstances(ctx, registry)
|
||||
} else {
|
||||
return killSingleInstance(ctx, registry, args[0])
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&killAll, "all", false, "kill all running instances")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func killSingleInstance(ctx context.Context, registry *global.ClientRegistry, address string) error {
|
||||
// Check if the instance exists in the registry
|
||||
_, err := registry.GetInstance(address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("instance %s not found in registry", address)
|
||||
}
|
||||
|
||||
fmt.Printf("Killing instance: %s\n", address)
|
||||
|
||||
// Get gRPC client and process info
|
||||
client, err := registry.GetClient(ctx, address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to instance %s: %w", address, err)
|
||||
}
|
||||
|
||||
processInfo, err := client.State.GetProcessInfo(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get process info for instance %s: %w", address, err)
|
||||
}
|
||||
|
||||
pid := int(processInfo.ProcessId)
|
||||
fmt.Printf("Terminating process PID %d...\n", pid)
|
||||
|
||||
// Kill the process
|
||||
if err := syscall.Kill(pid, syscall.SIGTERM); err != nil {
|
||||
return fmt.Errorf("failed to kill process %d: %w", pid, err)
|
||||
}
|
||||
|
||||
// Wait for the instance to remove itself from registry
|
||||
fmt.Printf("Waiting for instance to clean up registry entry...\n")
|
||||
for i := 0; i < 5; i++ {
|
||||
time.Sleep(1 * time.Second)
|
||||
if !registry.HasInstanceAtAddress(address) {
|
||||
fmt.Printf("Instance %s successfully killed and removed from registry.\n", address)
|
||||
|
||||
// Update default instance if needed
|
||||
instances, err := registry.ListInstancesCleaned(ctx)
|
||||
if err == nil && len(instances) > 0 {
|
||||
// ensureDefaultInstance logic will handle setting a new default
|
||||
defaultInstance := registry.GetDefaultInstance()
|
||||
if defaultInstance == address || defaultInstance == "" {
|
||||
if len(instances) > 0 {
|
||||
if err := registry.SetDefaultInstance(instances[0].Address); err == nil {
|
||||
fmt.Printf("Updated default instance to: %s\n", instances[0].Address)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("instance killed but failed to remove itself from registry within 5 seconds")
|
||||
}
|
||||
|
||||
func killAllInstances(ctx context.Context, registry *global.ClientRegistry) error {
|
||||
// Get all instances from registry
|
||||
instances, err := registry.ListInstancesCleaned(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list instances: %w", err)
|
||||
}
|
||||
|
||||
if len(instances) == 0 {
|
||||
fmt.Println("No Cline instances found to kill.")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("Killing %d instances...\n", len(instances))
|
||||
|
||||
var killResults []killResult
|
||||
|
||||
// Kill all instances
|
||||
for _, instance := range instances {
|
||||
result := killInstanceProcess(ctx, registry, instance.Address)
|
||||
killResults = append(killResults, result)
|
||||
|
||||
if result.err != nil {
|
||||
fmt.Printf("✗ Failed to kill %s: %v\n", instance.Address, result.err)
|
||||
} else if result.alreadyDead {
|
||||
fmt.Printf("⚠ Instance %s appears to be already dead\n", instance.Address)
|
||||
} else {
|
||||
fmt.Printf("✓ Killed %s (PID %d)\n", instance.Address, result.pid)
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for all instances to clean up their registry entries
|
||||
fmt.Printf("Waiting for instances to clean up registry entries...\n")
|
||||
|
||||
maxWaitTime := 10 // seconds
|
||||
for i := 0; i < maxWaitTime; i++ {
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
remainingInstances, err := registry.ListInstancesCleaned(ctx)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: failed to check registry status: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(remainingInstances) == 0 {
|
||||
fmt.Printf("✓ All instances successfully removed from registry.\n")
|
||||
break
|
||||
}
|
||||
|
||||
if i == maxWaitTime-1 {
|
||||
fmt.Printf("⚠ %d instances still in registry after %d seconds\n", len(remainingInstances), maxWaitTime)
|
||||
for _, remaining := range remainingInstances {
|
||||
fmt.Printf(" - %s\n", remaining.Address)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Print summary
|
||||
successful := 0
|
||||
failed := 0
|
||||
alreadyDead := 0
|
||||
|
||||
for _, result := range killResults {
|
||||
if result.err != nil {
|
||||
failed++
|
||||
} else if result.alreadyDead {
|
||||
alreadyDead++
|
||||
} else {
|
||||
successful++
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\nSummary: ")
|
||||
if successful > 0 {
|
||||
fmt.Printf("Successfully killed %d instances. ", successful)
|
||||
}
|
||||
if alreadyDead > 0 {
|
||||
fmt.Printf("%d were already dead. ", alreadyDead)
|
||||
}
|
||||
if failed > 0 {
|
||||
fmt.Printf("%d failures.", failed)
|
||||
return fmt.Errorf("failed to kill %d out of %d instances", failed, len(instances))
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type killResult struct {
|
||||
address string
|
||||
pid int
|
||||
alreadyDead bool
|
||||
err error
|
||||
}
|
||||
|
||||
func killInstanceProcess(ctx context.Context, registry *global.ClientRegistry, address string) killResult {
|
||||
// Get gRPC client and process info
|
||||
client, err := registry.GetClient(ctx, address)
|
||||
if err != nil {
|
||||
return killResult{address: address, alreadyDead: true, err: nil}
|
||||
}
|
||||
|
||||
processInfo, err := client.State.GetProcessInfo(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return killResult{address: address, alreadyDead: true, err: nil}
|
||||
}
|
||||
|
||||
pid := int(processInfo.ProcessId)
|
||||
|
||||
// Kill the process
|
||||
if err := syscall.Kill(pid, syscall.SIGTERM); err != nil {
|
||||
return killResult{address: address, pid: pid, err: err}
|
||||
}
|
||||
|
||||
return killResult{address: address, pid: pid, err: nil}
|
||||
}
|
||||
|
||||
func newInstanceListCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Aliases: []string{"l"},
|
||||
Short: "List all registered Cline instances",
|
||||
Long: `List all registered Cline instances with their status and connection details.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if global.Clients == nil {
|
||||
return fmt.Errorf("clients not initialized")
|
||||
}
|
||||
|
||||
ctx := cmd.Context()
|
||||
registry := global.Clients.GetRegistry()
|
||||
|
||||
// Load, cleanup stale local entries, and update health
|
||||
instances, err := registry.ListInstancesCleaned(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list instances: %w", err)
|
||||
}
|
||||
defaultInstance := registry.GetDefaultInstance()
|
||||
|
||||
if len(instances) == 0 {
|
||||
fmt.Println("No Cline instances found.")
|
||||
fmt.Println("Run 'cline instance new' to start a new instance, or 'cline task new \"...\"' to auto-start one.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Always output a table
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "ADDRESS\tSTATUS\tVERSION\tLAST SEEN\tPID\tDEFAULT")
|
||||
|
||||
for _, instance := range instances {
|
||||
isDefault := ""
|
||||
if instance.Address == defaultInstance {
|
||||
isDefault = "*"
|
||||
}
|
||||
|
||||
lastSeen := instance.LastSeen.Format("15:04:05")
|
||||
if time.Since(instance.LastSeen) > 24*time.Hour {
|
||||
lastSeen = instance.LastSeen.Format("2006-01-02")
|
||||
}
|
||||
|
||||
// Get PID via RPC if instance is healthy
|
||||
pid := "N/A"
|
||||
if instance.Status == grpc_health_v1.HealthCheckResponse_SERVING {
|
||||
if client, err := registry.GetClient(ctx, instance.Address); err == nil {
|
||||
if processInfo, err := client.State.GetProcessInfo(ctx, &cline.EmptyRequest{}); err == nil {
|
||||
pid = fmt.Sprintf("%d", processInfo.ProcessId)
|
||||
// Update version from RPC if available
|
||||
if processInfo.Version != nil && *processInfo.Version != "" && *processInfo.Version != "unknown" {
|
||||
instance.Version = *processInfo.Version
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
|
||||
instance.Address,
|
||||
instance.Status,
|
||||
instance.Version,
|
||||
lastSeen,
|
||||
pid,
|
||||
isDefault,
|
||||
)
|
||||
}
|
||||
|
||||
w.Flush()
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newInstanceUseCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "use <address>",
|
||||
Aliases: []string{"u"},
|
||||
Short: "Set the default Cline instance",
|
||||
Long: `Set the default Cline instance to use for subsequent commands.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
address := args[0]
|
||||
|
||||
if global.Clients == nil {
|
||||
return fmt.Errorf("clients not initialized")
|
||||
}
|
||||
|
||||
registry := global.Clients.GetRegistry()
|
||||
|
||||
// Verify the instance exists
|
||||
_, err := registry.GetInstance(address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("instance %s not found. Run 'cline instance list' to see available instances", address)
|
||||
}
|
||||
|
||||
// Set as default
|
||||
if err := registry.SetDefaultInstance(address); err != nil {
|
||||
return fmt.Errorf("failed to set default instance: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Switched to instance: %s\n", address)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newInstanceNewCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "new",
|
||||
Aliases: []string{"n"},
|
||||
Short: "Create a new Cline instance",
|
||||
Long: `Create a new Cline instance with automatically assigned ports.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
if global.Clients == nil {
|
||||
return fmt.Errorf("clients not initialized")
|
||||
}
|
||||
|
||||
fmt.Println("Starting new Cline instance...")
|
||||
|
||||
instance, err := global.Clients.StartNewInstance(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start instance: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Successfully started new instance:\n")
|
||||
fmt.Printf(" Address: %s\n", instance.Address)
|
||||
fmt.Printf(" Core Port: %d\n", instance.CorePort())
|
||||
fmt.Printf(" Host Bridge Port: %d\n", instance.HostPort())
|
||||
|
||||
// Check if this is now the default instance
|
||||
registry := global.Clients.GetRegistry()
|
||||
if registry.GetDefaultInstance() == instance.Address {
|
||||
fmt.Printf(" Status: Default instance\n")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -1,366 +0,0 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/common"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"google.golang.org/grpc/health/grpc_health_v1"
|
||||
)
|
||||
|
||||
// normalizeAddressVariants returns address variants to try when querying SQLite.
|
||||
// Handles localhost/127.0.0.1 equivalence by returning both forms.
|
||||
func normalizeAddressVariants(address string) []string {
|
||||
variants := []string{address}
|
||||
|
||||
// Extract host and port
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return variants
|
||||
}
|
||||
|
||||
// Add the alternate form for localhost/127.0.0.1
|
||||
if host == "localhost" {
|
||||
variants = append(variants, net.JoinHostPort("127.0.0.1", port))
|
||||
} else if host == "127.0.0.1" {
|
||||
variants = append(variants, net.JoinHostPort("localhost", port))
|
||||
}
|
||||
|
||||
return variants
|
||||
}
|
||||
|
||||
// LockManager provides access to the SQLite locks database
|
||||
type LockManager struct {
|
||||
dbPath string
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewLockManager creates a new lock manager
|
||||
func NewLockManager(clineDir string) (*LockManager, error) {
|
||||
dbPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "locks.db")
|
||||
|
||||
// Ensure the directory exists (for future DB creation by cline-core)
|
||||
dbDir := filepath.Dir(dbPath)
|
||||
if err := os.MkdirAll(dbDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create database directory: %w", err)
|
||||
}
|
||||
|
||||
// Check if database exists
|
||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||
// Database doesn't exist - return manager with nil db
|
||||
// All methods already handle this gracefully!
|
||||
return &LockManager{dbPath: dbPath, db: nil}, nil
|
||||
}
|
||||
|
||||
// Database exists - open it normally (no schema creation)
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
// If we can't open existing database, return nil db manager
|
||||
return &LockManager{dbPath: dbPath, db: nil}, nil
|
||||
}
|
||||
|
||||
// Test the connection
|
||||
if err := db.Ping(); err != nil {
|
||||
db.Close()
|
||||
// If connection fails, return nil db manager
|
||||
return &LockManager{dbPath: dbPath, db: nil}, nil
|
||||
}
|
||||
|
||||
return &LockManager{
|
||||
dbPath: dbPath,
|
||||
db: db,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ensureConnection attempts to establish a database connection if one doesn't exist
|
||||
func (lm *LockManager) ensureConnection() error {
|
||||
// If we already have a connection, we're done
|
||||
if lm.db != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if database exists now (created by cline-core)
|
||||
if _, err := os.Stat(lm.dbPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("database not available")
|
||||
}
|
||||
|
||||
// Database exists, try to connect
|
||||
db, err := sql.Open("sqlite3", lm.dbPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
db.Close()
|
||||
return fmt.Errorf("database connection failed: %w", err)
|
||||
}
|
||||
|
||||
// Success! Update our connection permanently
|
||||
lm.db = db
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the database connection
|
||||
func (lm *LockManager) Close() error {
|
||||
if lm.db != nil {
|
||||
return lm.db.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetInstanceLocks returns all instance locks
|
||||
func (lm *LockManager) GetInstanceLocks() ([]common.LockRow, error) {
|
||||
if err := lm.ensureConnection(); err != nil {
|
||||
return []common.LockRow{}, nil
|
||||
}
|
||||
|
||||
query := common.SelectInstanceLocksSQL
|
||||
|
||||
rows, err := lm.db.Query(query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query instance locks: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var locks []common.LockRow
|
||||
for rows.Next() {
|
||||
var lock common.LockRow
|
||||
err := rows.Scan(&lock.ID, &lock.HeldBy, &lock.LockType, &lock.LockTarget, &lock.LockedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan lock row: %w", err)
|
||||
}
|
||||
locks = append(locks, lock)
|
||||
}
|
||||
|
||||
return locks, nil
|
||||
}
|
||||
|
||||
// RemoveInstanceLock removes an instance lock by address
|
||||
func (lm *LockManager) RemoveInstanceLock(address string) error {
|
||||
if err := lm.ensureConnection(); err != nil {
|
||||
return nil // Gracefully handle missing database for cleanup operations
|
||||
}
|
||||
|
||||
query := common.DeleteInstanceLockSQL
|
||||
_, err := lm.db.Exec(query, address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove instance lock: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasInstanceAtAddress checks if an instance exists at the given address
|
||||
func (lm *LockManager) HasInstanceAtAddress(address string) (bool, error) {
|
||||
if err := lm.ensureConnection(); err != nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
query := common.CountInstanceLockSQL
|
||||
var count int
|
||||
err := lm.db.QueryRow(query, address).Scan(&count)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to check instance existence: %w", err)
|
||||
}
|
||||
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// GetInstanceInfo returns instance information directly from SQLite.
|
||||
// Handles localhost/127.0.0.1 equivalence by trying both variants.
|
||||
func (lm *LockManager) GetInstanceInfo(address string) (*common.CoreInstanceInfo, error) {
|
||||
if err := lm.ensureConnection(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query := common.SelectInstanceLockByHolderSQL
|
||||
variants := normalizeAddressVariants(address)
|
||||
|
||||
var heldBy, lockTarget string
|
||||
var lockedAt int64
|
||||
var lastErr error
|
||||
|
||||
// Try each address variant (e.g., localhost:50607 and 127.0.0.1:50607)
|
||||
for _, variant := range variants {
|
||||
err := lm.db.QueryRow(query, variant).Scan(&heldBy, &lockTarget, &lockedAt)
|
||||
if err == nil {
|
||||
// Found it!
|
||||
return &common.CoreInstanceInfo{
|
||||
Address: heldBy,
|
||||
HostServiceAddress: lockTarget,
|
||||
Status: grpc_health_v1.HealthCheckResponse_UNKNOWN,
|
||||
LastSeen: time.Unix(lockedAt/1000, 0),
|
||||
}, nil
|
||||
}
|
||||
if err != sql.ErrNoRows {
|
||||
// Real error (not just "not found"), save it
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
|
||||
// None of the variants were found
|
||||
if lastErr != nil {
|
||||
return nil, fmt.Errorf("failed to query instance: %w", lastErr)
|
||||
}
|
||||
return nil, fmt.Errorf("instance %s not found", address)
|
||||
}
|
||||
|
||||
// ListInstancesWithHealthCheck returns all instances with real-time health checks
|
||||
func (lm *LockManager) ListInstancesWithHealthCheck(ctx context.Context) ([]*common.CoreInstanceInfo, error) {
|
||||
if err := lm.ensureConnection(); err != nil {
|
||||
return []*common.CoreInstanceInfo{}, nil
|
||||
}
|
||||
|
||||
// Get all instance locks
|
||||
locks, err := lm.GetInstanceLocks()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get instance locks: %w", err)
|
||||
}
|
||||
|
||||
var instances []*common.CoreInstanceInfo
|
||||
|
||||
for _, lock := range locks {
|
||||
// Create instance info using actual SQLite data
|
||||
status, err := common.PerformHealthCheck(ctx, lock.HeldBy)
|
||||
if status != grpc_health_v1.HealthCheckResponse_SERVING || err != nil {
|
||||
time.Sleep(1 * time.Second)
|
||||
status, err = common.PerformHealthCheck(ctx, lock.HeldBy)
|
||||
}
|
||||
|
||||
info := &common.CoreInstanceInfo{
|
||||
Address: lock.HeldBy,
|
||||
HostServiceAddress: lock.LockTarget,
|
||||
Status: status,
|
||||
LastSeen: time.Unix(lock.LockedAt/1000, 0),
|
||||
}
|
||||
|
||||
instances = append(instances, info)
|
||||
}
|
||||
|
||||
return instances, nil
|
||||
}
|
||||
|
||||
// GetDefaultInstance reads the default instance from the settings file
|
||||
func GetDefaultInstance(clineDir string) (string, error) {
|
||||
settingsPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "settings", "cli-default-instance.json")
|
||||
|
||||
data, err := os.ReadFile(settingsPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", nil
|
||||
}
|
||||
return "", fmt.Errorf("failed to read default instance file: %w", err)
|
||||
}
|
||||
|
||||
var defaultInstance common.DefaultCoreInstance
|
||||
if err := json.Unmarshal(data, &defaultInstance); err != nil {
|
||||
return "", fmt.Errorf("failed to parse default instance JSON: %w", err)
|
||||
}
|
||||
|
||||
if defaultInstance.Address == "" {
|
||||
return "", fmt.Errorf("default instance not set in settings file")
|
||||
}
|
||||
|
||||
return defaultInstance.Address, nil
|
||||
}
|
||||
|
||||
// SetDefaultInstance writes the default instance to the settings file with proper locking
|
||||
func SetDefaultInstance(clineDir, address string) error {
|
||||
// Create lock manager for this operation
|
||||
lockManager, err := NewLockManager(clineDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Warning: SQLite unavailable, writing without lock: %v\n", err)
|
||||
}
|
||||
defer lockManager.Close()
|
||||
|
||||
settingsPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "settings", "cli-default-instance.json")
|
||||
|
||||
// Generate a unique identifier for this CLI process
|
||||
heldBy := fmt.Sprintf("cli-process-%d", os.Getpid())
|
||||
|
||||
// Use file lock for the write operation
|
||||
return lockManager.WithFileLock(settingsPath, heldBy, func() error {
|
||||
return writeDefaultInstanceJSONToDisk(clineDir, address)
|
||||
})
|
||||
}
|
||||
|
||||
func writeDefaultInstanceJSONToDisk(clineDir, address string) error {
|
||||
settingsDir := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "settings")
|
||||
if err := os.MkdirAll(settingsDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create settings directory: %w", err)
|
||||
}
|
||||
|
||||
settingsPath := filepath.Join(settingsDir, "cli-default-instance.json")
|
||||
|
||||
payload := common.DefaultCoreInstance{
|
||||
Address: address,
|
||||
LastUpdated: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(payload, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal default instance JSON: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(settingsPath, data, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write default instance file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AcquireFileLock attempts to acquire a file lock
|
||||
func (lm *LockManager) AcquireFileLock(filePath, heldBy string) error {
|
||||
if err := lm.ensureConnection(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now().Unix() * 1000 // Convert to milliseconds
|
||||
|
||||
query := common.InsertFileLockSQL
|
||||
|
||||
_, err := lm.db.Exec(query, heldBy, filePath, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to acquire file lock for %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReleaseFileLock releases a file lock
|
||||
func (lm *LockManager) ReleaseFileLock(filePath, heldBy string) error {
|
||||
if lm.db == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
query := common.DeleteFileLockSQL
|
||||
|
||||
_, err := lm.db.Exec(query, heldBy, filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to release file lock for %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// WithFileLock executes a function while holding a file lock
|
||||
func (lm *LockManager) WithFileLock(filePath, heldBy string, fn func() error) error {
|
||||
if err := lm.AcquireFileLock(filePath, heldBy); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if releaseErr := lm.ReleaseFileLock(filePath, heldBy); releaseErr != nil {
|
||||
fmt.Printf("Warning: Failed to release file lock for %s: %v\n", filePath, releaseErr)
|
||||
}
|
||||
}()
|
||||
|
||||
return fn()
|
||||
}
|
||||
@@ -1,444 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/task"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewTaskCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "task",
|
||||
Aliases: []string{"t"},
|
||||
Short: "Manage Cline tasks",
|
||||
Long: `Create, monitor, and manage Cline AI tasks.`,
|
||||
}
|
||||
|
||||
cmd.AddCommand(newTaskNewCommand())
|
||||
cmd.AddCommand(newTaskCancelCommand())
|
||||
cmd.AddCommand(newTaskFollowCommand())
|
||||
cmd.AddCommand(newTaskSendCommand())
|
||||
cmd.AddCommand(newTaskViewCommand())
|
||||
cmd.AddCommand(newTaskListCommand())
|
||||
cmd.AddCommand(newTaskResumeCommand())
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
var taskManager *task.Manager
|
||||
|
||||
func ensureTaskManager(ctx context.Context, address string) error {
|
||||
if taskManager == nil || (address != "" && taskManager.GetCurrentInstance() != address) {
|
||||
var err error
|
||||
var instanceAddress string
|
||||
|
||||
if address != "" {
|
||||
// Ensure instance exists at the specified address
|
||||
if err := ensureInstanceAtAddress(ctx, address); err != nil {
|
||||
return fmt.Errorf("failed to ensure instance at address %s: %w", address, err)
|
||||
}
|
||||
taskManager, err = task.NewManagerForAddress(ctx, address)
|
||||
instanceAddress = address
|
||||
} else {
|
||||
// Ensure default instance exists
|
||||
if err := ensureDefaultInstance(ctx); err != nil {
|
||||
return fmt.Errorf("failed to ensure default instance: %w", err)
|
||||
}
|
||||
taskManager, err = task.NewManagerForDefault(ctx)
|
||||
if err == nil {
|
||||
instanceAddress = taskManager.GetCurrentInstance()
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create task manager: %w", err)
|
||||
}
|
||||
|
||||
// Always set the instance we're using as the default
|
||||
registry := global.Clients.GetRegistry()
|
||||
if err := registry.SetDefaultInstance(instanceAddress); err != nil {
|
||||
// Log warning but don't fail - this is not critical
|
||||
fmt.Printf("Warning: failed to set default instance: %v\n", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureInstanceAtAddress ensures an instance exists at the given address
|
||||
func ensureInstanceAtAddress(ctx context.Context, address string) error {
|
||||
if global.Clients == nil {
|
||||
return fmt.Errorf("global clients not initialized")
|
||||
}
|
||||
return global.Clients.EnsureInstanceAtAddress(ctx, address)
|
||||
}
|
||||
|
||||
// ensureDefaultInstance ensures a default instance exists
|
||||
func ensureDefaultInstance(ctx context.Context) error {
|
||||
if global.Clients == nil {
|
||||
return fmt.Errorf("global clients not initialized")
|
||||
}
|
||||
|
||||
// Check if we have any instances in the registry
|
||||
registry := global.Clients.GetRegistry()
|
||||
if registry.GetDefaultInstance() == "" {
|
||||
// No default instance, start a new one
|
||||
instance, err := global.Clients.StartNewInstance(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start new default instance: %w", err)
|
||||
}
|
||||
|
||||
// Set the new instance as default
|
||||
if err := registry.SetDefaultInstance(instance.Address); err != nil {
|
||||
return fmt.Errorf("failed to set default instance: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func newTaskNewCommand() *cobra.Command {
|
||||
var (
|
||||
images []string
|
||||
files []string
|
||||
wait bool
|
||||
workspaces []string
|
||||
address string
|
||||
mode string
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "new <prompt>",
|
||||
Aliases: []string{"n"},
|
||||
Short: "Create a new task",
|
||||
Long: `Create a new Cline task with the specified prompt. If no Cline instance exists at the specified address, a new one will be started automatically.`,
|
||||
Args: cobra.MinimumNArgs(0),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
// Get content from both args and stdin
|
||||
prompt, err := getContentFromStdinAndArgs(args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read prompt: %w", err)
|
||||
}
|
||||
|
||||
// Validate that prompt is passed in call
|
||||
if prompt == "" {
|
||||
return fmt.Errorf("prompt required: provide as argument or pipe via stdin")
|
||||
}
|
||||
|
||||
// Ensure task manager is initialized
|
||||
if err := ensureTaskManager(ctx, address); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set mode if provided
|
||||
if mode != "" {
|
||||
if err := taskManager.SetMode(ctx, mode, nil, nil, nil); err != nil {
|
||||
return fmt.Errorf("failed to set mode: %w", err)
|
||||
}
|
||||
fmt.Printf("Mode set to: %s\n", mode)
|
||||
}
|
||||
|
||||
// Create the task
|
||||
taskID, err := taskManager.CreateTask(ctx, prompt, images, files, workspaces)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create task: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Task created successfully with ID: %s\n", taskID)
|
||||
fmt.Printf("Using instance: %s\n", taskManager.GetCurrentInstance())
|
||||
|
||||
// Wait for completion if requested
|
||||
if wait {
|
||||
fmt.Println("Following task conversation...")
|
||||
return taskManager.FollowConversation(ctx)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringSliceVarP(&images, "image", "i", nil, "attach image files")
|
||||
cmd.Flags().StringSliceVarP(&files, "file", "f", nil, "attach files")
|
||||
cmd.Flags().BoolVar(&wait, "wait", false, "wait for task completion")
|
||||
cmd.Flags().StringSliceVarP(&workspaces, "workdir", "w", nil, "workdir directory paths")
|
||||
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
|
||||
cmd.Flags().StringVarP(&mode, "mode", "m", "", "mode (act|plan)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newTaskCancelCommand() *cobra.Command {
|
||||
var address string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "cancel",
|
||||
Aliases: []string{"c"},
|
||||
Short: "Cancel the current task",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
if err := ensureTaskManager(ctx, address); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := taskManager.CancelTask(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("Task cancelled successfully")
|
||||
fmt.Printf("Instance: %s\n", taskManager.GetCurrentInstance())
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newTaskSendCommand() *cobra.Command {
|
||||
var (
|
||||
images []string
|
||||
files []string
|
||||
address string
|
||||
mode string
|
||||
approve string
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "send [message]",
|
||||
Aliases: []string{"s"},
|
||||
Short: "Send a followup message to the current task and/or update mode/approve",
|
||||
Long: `Send a followup message to continue the conversation with the current task and/or update mode/approve.`,
|
||||
Args: cobra.MinimumNArgs(0),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
// Get content from both args and stdin
|
||||
message, err := getContentFromStdinAndArgs(args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read message: %w", err)
|
||||
}
|
||||
|
||||
if message == "" && len(images) == 0 && len(files) == 0 && mode == "" && approve == "" {
|
||||
return fmt.Errorf("content (message, files, images) required unless using --mode or --approve flags")
|
||||
}
|
||||
|
||||
if approve != "" && approve != "true" && approve != "false" {
|
||||
return fmt.Errorf("--approve must be 'true' or 'false'")
|
||||
}
|
||||
|
||||
if approve != "" && mode != "" {
|
||||
return fmt.Errorf("cannot use --approve and --mode together")
|
||||
}
|
||||
|
||||
// Ensure task manager is initialized
|
||||
if err := ensureTaskManager(ctx, address); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sendDisabled, err := taskManager.CheckSendDisabled(ctx)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if message can be sent: %w", err)
|
||||
}
|
||||
|
||||
if sendDisabled {
|
||||
fmt.Println("Cannot send message: task is currently busy")
|
||||
return nil
|
||||
}
|
||||
|
||||
if mode != "" {
|
||||
if err := taskManager.SetModeAndSendMessage(ctx, mode, message, images, files); err != nil {
|
||||
return fmt.Errorf("failed to set mode and send message: %w", err)
|
||||
}
|
||||
fmt.Printf("Mode set to %s and message sent successfully.\n", mode)
|
||||
|
||||
} else {
|
||||
if err := taskManager.SendMessage(ctx, message, images, files, approve); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Message sent successfully.\n")
|
||||
}
|
||||
|
||||
fmt.Printf("Instance: %s\n", taskManager.GetCurrentInstance())
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringSliceVarP(&images, "image", "i", nil, "attach image files")
|
||||
cmd.Flags().StringSliceVarP(&files, "file", "f", nil, "attach files")
|
||||
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
|
||||
cmd.Flags().StringVarP(&mode, "mode", "m", "", "mode (act|plan)")
|
||||
cmd.Flags().StringVarP(&approve, "approve", "a", "", "approve (true) or deny (false) pending request")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newTaskFollowCommand() *cobra.Command {
|
||||
var address string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "follow",
|
||||
Aliases: []string{"f"},
|
||||
Short: "Follow current task conversation in real-time",
|
||||
Long: `Follow the current task conversation, displaying new messages as they arrive in real-time.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
if err := ensureTaskManager(ctx, address); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Using instance: %s\n", taskManager.GetCurrentInstance())
|
||||
|
||||
return taskManager.FollowConversation(ctx)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newTaskViewCommand() *cobra.Command {
|
||||
var (
|
||||
current bool
|
||||
summary bool
|
||||
address string
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "view",
|
||||
Aliases: []string{"v"},
|
||||
Short: "View task conversation",
|
||||
Long: `Output conversation until next completion, with options for current state or summary only.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
if err := ensureTaskManager(ctx, address); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Using instance: %s\n", taskManager.GetCurrentInstance())
|
||||
|
||||
if current {
|
||||
return taskManager.ShowConversation(ctx)
|
||||
} else if summary {
|
||||
return taskManager.GatherFinalSummary(ctx)
|
||||
} else {
|
||||
return taskManager.FollowConversationUntilCompletion(ctx)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVarP(¤t, "current", "c", false, "output current conversation without following")
|
||||
cmd.Flags().BoolVarP(&summary, "summary", "s", false, "outputs only the completion summary")
|
||||
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newTaskListCommand() *cobra.Command {
|
||||
var address string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Aliases: []string{"l"},
|
||||
Short: "List recent task history",
|
||||
Long: `Display recent tasks from task history.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
// Ensure task manager is initialized
|
||||
if err := ensureTaskManager(ctx, address); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Using instance: %s\n", taskManager.GetCurrentInstance())
|
||||
|
||||
return taskManager.ListTasks(ctx)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newTaskResumeCommand() *cobra.Command {
|
||||
var address string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "resume <task-id>",
|
||||
Aliases: []string{"r"},
|
||||
Short: "Resume a task by ID",
|
||||
Long: `Resume an existing task by ID.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
taskID := args[0]
|
||||
|
||||
// Ensure task manager is initialized
|
||||
if err := ensureTaskManager(ctx, address); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Using instance: %s\n", taskManager.GetCurrentInstance())
|
||||
|
||||
return taskManager.ResumeTask(ctx, taskID)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// getContentFromStdinAndArgs reads content from both command line args and stdin, and combines them
|
||||
func getContentFromStdinAndArgs(args []string) (string, error) {
|
||||
var content strings.Builder
|
||||
|
||||
// Add command line args first (if any)
|
||||
if len(args) > 0 {
|
||||
content.WriteString(strings.Join(args, " "))
|
||||
}
|
||||
|
||||
// Check if stdin has data
|
||||
stat, err := os.Stdin.Stat()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to stat stdin: %w", err)
|
||||
}
|
||||
|
||||
// Check if data is being piped to stdin
|
||||
if (stat.Mode() & os.ModeCharDevice) == 0 {
|
||||
stdinBytes, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read from stdin: %w", err)
|
||||
}
|
||||
|
||||
stdinContent := strings.TrimSpace(string(stdinBytes))
|
||||
if stdinContent != "" {
|
||||
if content.Len() > 0 {
|
||||
content.WriteString(" ")
|
||||
}
|
||||
content.WriteString(stdinContent)
|
||||
}
|
||||
}
|
||||
|
||||
return content.String(), nil
|
||||
}
|
||||
|
||||
// CleanupTaskManager cleans up the task manager resources
|
||||
func CleanupTaskManager() {
|
||||
if taskManager != nil {
|
||||
taskManager.Cleanup()
|
||||
}
|
||||
}
|
||||
@@ -1,946 +0,0 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/display"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/handlers"
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
"github.com/cline/grpc-go/client"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
// Manager handles task execution and message display
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
client *client.ClineClient
|
||||
clientAddress string
|
||||
state *types.ConversationState
|
||||
renderer *display.Renderer
|
||||
streamingDisplay *display.StreamingDisplay
|
||||
handlerRegistry *handlers.HandlerRegistry
|
||||
}
|
||||
|
||||
// NewManager creates a new task manager
|
||||
func NewManager(client *client.ClineClient) *Manager {
|
||||
state := types.NewConversationState()
|
||||
renderer := display.NewRenderer()
|
||||
streamingDisplay := display.NewStreamingDisplay(state, renderer)
|
||||
|
||||
// Create handler registry and register handlers
|
||||
registry := handlers.NewHandlerRegistry()
|
||||
registry.Register(handlers.NewAskHandler())
|
||||
registry.Register(handlers.NewSayHandler())
|
||||
|
||||
return &Manager{
|
||||
client: client,
|
||||
clientAddress: "", // Will be set when client is provided
|
||||
state: state,
|
||||
renderer: renderer,
|
||||
streamingDisplay: streamingDisplay,
|
||||
handlerRegistry: registry,
|
||||
}
|
||||
}
|
||||
|
||||
// NewManagerForAddress creates a new task manager for a specific instance address
|
||||
func NewManagerForAddress(ctx context.Context, address string) (*Manager, error) {
|
||||
client, err := global.GetClientForAddress(ctx, address)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get client for address %s: %w", address, err)
|
||||
}
|
||||
|
||||
manager := NewManager(client)
|
||||
manager.clientAddress = address
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
// NewManagerForDefault creates a new task manager using the default instance
|
||||
func NewManagerForDefault(ctx context.Context) (*Manager, error) {
|
||||
client, err := global.GetDefaultClient(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get default client: %w", err)
|
||||
}
|
||||
|
||||
manager := NewManager(client)
|
||||
|
||||
// Get the default instance address
|
||||
if global.Clients != nil {
|
||||
manager.clientAddress = global.Clients.GetRegistry().GetDefaultInstance()
|
||||
}
|
||||
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
// SwitchToInstance switches the manager to use a different Cline instance
|
||||
func (m *Manager) SwitchToInstance(ctx context.Context, address string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Get client for the new address
|
||||
newClient, err := global.GetClientForAddress(ctx, address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get client for address %s: %w", address, err)
|
||||
}
|
||||
|
||||
// Update the client and address
|
||||
m.client = newClient
|
||||
m.clientAddress = address
|
||||
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("Switched to instance: %s", address)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCurrentInstance returns the address of the current instance
|
||||
func (m *Manager) GetCurrentInstance() string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.clientAddress
|
||||
}
|
||||
|
||||
// CreateTask creates a new task
|
||||
func (m *Manager) CreateTask(ctx context.Context, prompt string, images, files []string, workspacePaths []string) (string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("Creating task: %s", prompt)
|
||||
if len(files) > 0 {
|
||||
m.renderer.RenderDebug("Files: %v", files)
|
||||
}
|
||||
if len(images) > 0 {
|
||||
m.renderer.RenderDebug("Images: %v", images)
|
||||
}
|
||||
if len(workspacePaths) > 0 {
|
||||
m.renderer.RenderDebug("Workspaces: %v", workspacePaths)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if there's an active task and cancel it first
|
||||
if err := m.cancelExistingTaskIfNeeded(ctx); err != nil {
|
||||
return "", fmt.Errorf("failed to cancel existing task: %w", err)
|
||||
}
|
||||
|
||||
// Create task request
|
||||
req := &cline.NewTaskRequest{
|
||||
Text: prompt,
|
||||
Images: images,
|
||||
Files: files,
|
||||
}
|
||||
|
||||
resp, err := m.client.Task.NewTask(ctx, req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create task: %w", err)
|
||||
}
|
||||
|
||||
taskID := resp.Value
|
||||
|
||||
return taskID, nil
|
||||
}
|
||||
|
||||
// cancelExistingTaskIfNeeded checks if there's an active task and cancels it
|
||||
func (m *Manager) cancelExistingTaskIfNeeded(ctx context.Context) error {
|
||||
// Try to get the current state to check if there's an active task
|
||||
state, err := m.client.State.GetLatestState(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
// If we can't get state, assume no active task and continue
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("Could not get state to check for active task: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Properly parse the state to check if there's actually an active task
|
||||
if state.StateJson != "" {
|
||||
var stateData types.ExtensionState
|
||||
if err := json.Unmarshal([]byte(state.StateJson), &stateData); err != nil {
|
||||
// If we can't parse state, assume no active task
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("Could not parse state JSON: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if there's actually an active task
|
||||
if stateData.CurrentTaskItem != nil && stateData.CurrentTaskItem.Id != "" {
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("Found active task %s, cancelling...", stateData.CurrentTaskItem.Id)
|
||||
}
|
||||
|
||||
// Cancel the existing task
|
||||
_, err := m.client.Task.CancelTask(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("Cancel task returned error: %v", err)
|
||||
}
|
||||
} else {
|
||||
fmt.Println("Cancelled existing task to start new one")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckSendDisabled determines if we can send a message to the current task
|
||||
// We duplicate the logic from buttonConfig::getButtonConfig
|
||||
func (m *Manager) CheckSendDisabled(ctx context.Context) (bool, error) {
|
||||
state, err := m.client.State.GetLatestState(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to get latest state: %w", err)
|
||||
}
|
||||
|
||||
messages, err := m.extractMessagesFromState(state.StateJson)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to extract messages: %w", err)
|
||||
}
|
||||
|
||||
if len(messages) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Use final message to perform validation
|
||||
lastMessage := messages[len(messages)-1]
|
||||
|
||||
// Error types which we allow sending on
|
||||
errorTypes := []string{
|
||||
string(types.AskTypeAPIReqFailed), // "api_req_failed"
|
||||
string(types.AskTypeMistakeLimitReached), // "mistake_limit_reached"
|
||||
string(types.AskTypeAutoApprovalMaxReached), // "auto_approval_max_req_reached"
|
||||
}
|
||||
|
||||
isError := false
|
||||
|
||||
// Check if message is an error type
|
||||
if lastMessage.Type == types.MessageTypeAsk {
|
||||
for _, errType := range errorTypes {
|
||||
if lastMessage.Ask == errType {
|
||||
isError = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Streaming and error check
|
||||
if lastMessage.Partial && !isError {
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("Send disabled: task is streaming and non-error")
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// All ask messages allow sending
|
||||
if lastMessage.Type == types.MessageTypeAsk {
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("Send enabled: ask message")
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Technically unnecessary but implements getButtonConfig 1-1
|
||||
if lastMessage.Type == types.MessageTypeSay && lastMessage.Say == string(types.SayTypeAPIReqStarted) {
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("Send disabled: API request is active")
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("Send disabled: default fallback")
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// SendMessage sends a followup message to the current task
|
||||
func (m *Manager) SendMessage(ctx context.Context, message string, images, files []string, approve string) error {
|
||||
responseType := "messageResponse"
|
||||
|
||||
if approve == "true" {
|
||||
responseType = "yesButtonClicked"
|
||||
}
|
||||
|
||||
if approve == "false" {
|
||||
responseType = "noButtonClicked"
|
||||
}
|
||||
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("Sending message: %s", message)
|
||||
if len(files) > 0 {
|
||||
m.renderer.RenderDebug("Files: %v", files)
|
||||
}
|
||||
if len(images) > 0 {
|
||||
m.renderer.RenderDebug("Images: %v", images)
|
||||
}
|
||||
}
|
||||
|
||||
// Send the followup message using AskResponse
|
||||
req := &cline.AskResponseRequest{
|
||||
ResponseType: responseType,
|
||||
Text: message,
|
||||
Images: images,
|
||||
Files: files,
|
||||
}
|
||||
|
||||
_, err := m.client.Task.AskResponse(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send message: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetMode sets the Plan/Act mode for the current Cline instance and optionally sends message
|
||||
func (m *Manager) SetMode(ctx context.Context, mode string, message *string, images, files []string) error {
|
||||
if mode != "act" && mode != "plan" {
|
||||
return fmt.Errorf("invalid mode '%s': must be 'act' or 'plan'", mode)
|
||||
}
|
||||
|
||||
var protoMode cline.PlanActMode
|
||||
if mode == "plan" {
|
||||
protoMode = cline.PlanActMode_PLAN
|
||||
} else {
|
||||
protoMode = cline.PlanActMode_ACT
|
||||
}
|
||||
|
||||
req := &cline.TogglePlanActModeRequest{
|
||||
Metadata: &cline.Metadata{},
|
||||
Mode: protoMode,
|
||||
}
|
||||
|
||||
if message != nil {
|
||||
req.ChatContent = &cline.ChatContent{
|
||||
Message: message,
|
||||
Images: images,
|
||||
Files: files,
|
||||
}
|
||||
}
|
||||
|
||||
_, err := m.client.State.TogglePlanActModeProto(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set mode to '%s': %w", mode, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetModeAndSendMessage sets the mode and sends a message in one operation
|
||||
// Handles task restoration internally if the mode switch cancels the current task
|
||||
func (m *Manager) SetModeAndSendMessage(ctx context.Context, mode, message string, images, files []string) error {
|
||||
if mode != "act" && mode != "plan" {
|
||||
return fmt.Errorf("invalid mode '%s': must be 'act' or 'plan'", mode)
|
||||
}
|
||||
|
||||
taskId, err := m.getCurrentTaskId(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current task ID: %w", err)
|
||||
}
|
||||
fmt.Printf("Current task ID: %s\n", taskId)
|
||||
|
||||
var protoMode cline.PlanActMode
|
||||
if mode == "plan" {
|
||||
protoMode = cline.PlanActMode_PLAN
|
||||
} else {
|
||||
protoMode = cline.PlanActMode_ACT
|
||||
}
|
||||
|
||||
req := &cline.TogglePlanActModeRequest{
|
||||
Metadata: &cline.Metadata{},
|
||||
Mode: protoMode,
|
||||
ChatContent: &cline.ChatContent{
|
||||
Message: &message,
|
||||
Images: images,
|
||||
Files: files,
|
||||
},
|
||||
}
|
||||
|
||||
result, err := m.client.State.TogglePlanActModeProto(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set mode to '%s': %w", mode, err)
|
||||
}
|
||||
|
||||
taskPreserved := result.Value
|
||||
|
||||
if taskPreserved {
|
||||
fmt.Printf("Message sent as part of mode change\n")
|
||||
return nil
|
||||
} else {
|
||||
if message != "" || len(images) > 0 || len(files) > 0 {
|
||||
fmt.Printf("Task was cancelled, restoring task ID: %s\n", taskId)
|
||||
|
||||
err = m.ReinitExistingTaskFromId(ctx, taskId)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to restore task: %w", err)
|
||||
}
|
||||
fmt.Printf("Task restored successfully\n")
|
||||
|
||||
// Hardcoded sleep should be replaced with a way to fetch whether task is ready algorithmically
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
err = m.SendMessage(ctx, message, images, files, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to send message: %w", err)
|
||||
}
|
||||
fmt.Printf("Message sent to restored task\n")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getCurrentTaskId extracts the current task ID from the server state
|
||||
func (m *Manager) getCurrentTaskId(ctx context.Context) (string, error) {
|
||||
// Get the latest state
|
||||
state, err := m.client.State.GetLatestState(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get state: %w", err)
|
||||
}
|
||||
|
||||
// Parse the server state JSON
|
||||
var stateData types.ExtensionState
|
||||
if err := json.Unmarshal([]byte(state.StateJson), &stateData); err != nil {
|
||||
return "", fmt.Errorf("failed to parse state JSON: %w", err)
|
||||
}
|
||||
|
||||
// Extract current task ID
|
||||
if stateData.CurrentTaskItem != nil && stateData.CurrentTaskItem.Id != "" {
|
||||
return stateData.CurrentTaskItem.Id, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no current task found in state")
|
||||
}
|
||||
|
||||
// ReinitExistingTaskFromId reinitializes an existing task from the given task ID
|
||||
func (m *Manager) ReinitExistingTaskFromId(ctx context.Context, taskId string) error {
|
||||
req := &cline.StringRequest{Value: taskId}
|
||||
resp, err := m.client.Task.ShowTaskWithId(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to reinitialize task %s: %w", taskId, err)
|
||||
}
|
||||
|
||||
fmt.Printf("Successfully reinitialized task: %s (ID: %s)\n", taskId, resp.Id)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResumeTask resumes an existing task by ID
|
||||
func (m *Manager) ResumeTask(ctx context.Context, taskID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("Resuming task: %s", taskID)
|
||||
}
|
||||
|
||||
// This call handles cancellation of any active task
|
||||
if err := m.ReinitExistingTaskFromId(ctx, taskID); err != nil {
|
||||
return fmt.Errorf("failed to resume task %s: %w", taskID, err)
|
||||
}
|
||||
|
||||
fmt.Printf("Task %s resumed successfully\n", taskID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelTask cancels the current task
|
||||
func (m *Manager) CancelTask(ctx context.Context) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
_, err := m.client.Task.CancelTask(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to cancel task: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListTasks retrieves and displays task history
|
||||
func (m *Manager) ListTasks(ctx context.Context) error {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
req := &cline.GetTaskHistoryRequest{
|
||||
FavoritesOnly: false,
|
||||
SearchQuery: "",
|
||||
SortBy: "oldest",
|
||||
CurrentWorkspaceOnly: false,
|
||||
}
|
||||
|
||||
resp, err := m.client.Task.GetTaskHistory(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get task history: %w", err)
|
||||
}
|
||||
|
||||
if len(resp.Tasks) == 0 {
|
||||
fmt.Println("No task history found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
return m.renderer.RenderTaskList(resp.Tasks)
|
||||
}
|
||||
|
||||
// GatherFinalSummary attempts to gather the latest completion_result output and display it
|
||||
func (m *Manager) GatherFinalSummary(ctx context.Context) error {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
state, err := m.client.State.GetLatestState(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get state: %w", err)
|
||||
}
|
||||
|
||||
messages, err := m.extractMessagesFromState(state.StateJson)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to extract messages: %w", err)
|
||||
}
|
||||
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
msg := messages[i]
|
||||
|
||||
// Check if this is a completion result SAY message
|
||||
if msg.IsSay() && msg.Say == string(types.SayTypeCompletionResult) {
|
||||
return m.displayMessage(msg, false, false, i)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ShowConversation displays the current conversation
|
||||
func (m *Manager) ShowConversation(ctx context.Context) error {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
// Get the latest state which contains messages
|
||||
state, err := m.client.State.GetLatestState(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get state: %w", err)
|
||||
}
|
||||
|
||||
// Parse the state JSON to extract messages
|
||||
messages, err := m.extractMessagesFromState(state.StateJson)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to extract messages: %w", err)
|
||||
}
|
||||
|
||||
if len(messages) == 0 {
|
||||
fmt.Println("No conversation history found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Display messages
|
||||
for i, msg := range messages {
|
||||
m.displayMessage(msg, false, false, i)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) FollowConversation(ctx context.Context) error {
|
||||
fmt.Println("Following task conversation... (Press Ctrl+C to exit)")
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
// Create stream coordinator
|
||||
coordinator := NewStreamCoordinator()
|
||||
|
||||
// Load history first
|
||||
totalMessageCount, err := m.loadAndDisplayRecentHistory(ctx)
|
||||
if err != nil {
|
||||
m.renderer.RenderDebug("Warning: Failed to load conversation history: %v", err)
|
||||
totalMessageCount = 0
|
||||
}
|
||||
coordinator.SetConversationTurnStartIndex(totalMessageCount)
|
||||
|
||||
fmt.Println("\n--- Live updates ---")
|
||||
|
||||
// Start both streams concurrently
|
||||
errChan := make(chan error, 2)
|
||||
|
||||
if global.Config.OutputFormat == "json" {
|
||||
go m.handleStateStream(ctx, coordinator, errChan, nil)
|
||||
} else {
|
||||
go m.handleStateStream(ctx, coordinator, errChan, nil)
|
||||
go m.handlePartialMessageStream(ctx, coordinator, errChan)
|
||||
}
|
||||
|
||||
// Wait for either stream to error or context cancellation
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case err := <-errChan:
|
||||
cancel()
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// FollowConversationUntilCompletion streams conversation updates until task completion
|
||||
func (m *Manager) FollowConversationUntilCompletion(ctx context.Context) error {
|
||||
fmt.Println("Streaming conversation until completion... (Press Ctrl+C to exit)")
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
// Create stream coordinator
|
||||
coordinator := NewStreamCoordinator()
|
||||
|
||||
// Get current message count without displaying history
|
||||
totalMessageCount, err := m.getCurrentMessageCount(ctx)
|
||||
if err != nil {
|
||||
m.renderer.RenderDebug("Warning: Failed to get current message count: %v", err)
|
||||
totalMessageCount = 0
|
||||
}
|
||||
coordinator.SetConversationTurnStartIndex(totalMessageCount)
|
||||
|
||||
// Start both streams concurrently
|
||||
errChan := make(chan error, 2)
|
||||
completionChan := make(chan bool, 1)
|
||||
|
||||
if global.Config.OutputFormat == "json" {
|
||||
go m.handleStateStream(ctx, coordinator, errChan, completionChan)
|
||||
} else {
|
||||
go m.handleStateStream(ctx, coordinator, errChan, completionChan)
|
||||
go m.handlePartialMessageStream(ctx, coordinator, errChan)
|
||||
}
|
||||
|
||||
// Wait for completion, error, or context cancellation
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-completionChan:
|
||||
cancel()
|
||||
return nil
|
||||
case err := <-errChan:
|
||||
cancel()
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// handleStateStream handles the SubscribeToState stream
|
||||
func (m *Manager) handleStateStream(ctx context.Context, coordinator *StreamCoordinator, errChan chan error, completionChan chan bool) {
|
||||
stateStream, err := m.client.State.SubscribeToState(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("failed to subscribe to state: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
stateUpdate, err := stateStream.Recv()
|
||||
if err != nil {
|
||||
m.renderer.RenderDebug("State stream receive error: %v", err)
|
||||
errChan <- fmt.Errorf("failed to receive state update: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
var pErr error
|
||||
|
||||
if global.Config.OutputFormat == "json" {
|
||||
pErr = m.processStateUpdateJsonMode(stateUpdate, coordinator, completionChan)
|
||||
} else {
|
||||
pErr = m.processStateUpdate(stateUpdate, coordinator, completionChan)
|
||||
}
|
||||
|
||||
if pErr != nil {
|
||||
m.renderer.RenderDebug("State processing error: %v", pErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) processStateUpdateJsonMode(stateUpdate *cline.State, coordinator *StreamCoordinator, completionChan chan bool) error {
|
||||
messages, err := m.extractMessagesFromState(stateUpdate.StateJson)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Process messages from current conversation turn onwards
|
||||
startIndex := coordinator.GetConversationTurnStartIndex()
|
||||
|
||||
var foundCompletion bool
|
||||
var displayedUsage bool
|
||||
|
||||
for i := startIndex; i < len(messages); i++ {
|
||||
msg := messages[i]
|
||||
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("State message %d: type=%s, say=%s", i, msg.Type, msg.Say)
|
||||
}
|
||||
|
||||
// Exit after we've seen a task completion & printed out the usage info
|
||||
if msg.Say == string(types.SayTypeCompletionResult) {
|
||||
foundCompletion = true
|
||||
}
|
||||
|
||||
// Determine if message is ready to be displayed now
|
||||
shouldDisplay := true
|
||||
|
||||
switch {
|
||||
case msg.Say == string(types.SayTypeAPIReqStarted):
|
||||
shouldDisplay = false
|
||||
apiInfo := types.APIRequestInfo{Cost: -1}
|
||||
if err := json.Unmarshal([]byte(msg.Text), &apiInfo); err == nil && apiInfo.Cost >= 0 {
|
||||
shouldDisplay = true
|
||||
displayedUsage = true
|
||||
}
|
||||
}
|
||||
|
||||
// Skip if message is partial, except for a specific edge case
|
||||
if msg.Partial {
|
||||
// Exception: display if type=say, text="", say="text"
|
||||
if msg.IsSay() && msg.Text == "" && msg.Say == string(types.SayTypeText) {
|
||||
shouldDisplay = true
|
||||
} else {
|
||||
shouldDisplay = false
|
||||
}
|
||||
}
|
||||
|
||||
// Display valid messages, exit as soon as we hit a non-valid message
|
||||
if shouldDisplay {
|
||||
coordinator.CompleteTurn(i + 1) // Mark the message as complete as soon as we print it
|
||||
m.displayMessage(msg, false, false, i)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// We only want to exit after we've displayed the usage, for the case of seeing completion result
|
||||
if completionChan != nil && foundCompletion && displayedUsage {
|
||||
completionChan <- true
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// processStateUpdate processes state updates and supports logic for handling task competion markers
|
||||
func (m *Manager) processStateUpdate(stateUpdate *cline.State, coordinator *StreamCoordinator, completionChan chan bool) error {
|
||||
messages, err := m.extractMessagesFromState(stateUpdate.StateJson)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Process messages from current conversation turn onwards
|
||||
startIndex := coordinator.GetConversationTurnStartIndex()
|
||||
|
||||
var foundCompletion bool
|
||||
var displayedUsage bool
|
||||
|
||||
for i := startIndex; i < len(messages); i++ {
|
||||
msg := messages[i]
|
||||
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("State message %d: type=%s, say=%s", i, msg.Type, msg.Say)
|
||||
}
|
||||
|
||||
// Exit after we've seen a task completion & printed out the usage info
|
||||
if msg.Say == string(types.SayTypeCompletionResult) {
|
||||
foundCompletion = true
|
||||
}
|
||||
|
||||
// Currently handling a subset of message types for displaying
|
||||
switch {
|
||||
case msg.Say == string(types.SayTypeUserFeedback):
|
||||
if !coordinator.IsProcessedInCurrentTurn("user_msg") {
|
||||
m.displayMessage(msg, false, false, i)
|
||||
coordinator.MarkProcessedInCurrentTurn("user_msg")
|
||||
}
|
||||
|
||||
case msg.Say == string(types.SayTypeCheckpointCreated):
|
||||
if !coordinator.IsProcessedInCurrentTurn("checkpoint") {
|
||||
m.displayMessage(msg, false, false, i)
|
||||
coordinator.MarkProcessedInCurrentTurn("checkpoint")
|
||||
}
|
||||
|
||||
case msg.Say == string(types.SayTypeAPIReqStarted):
|
||||
apiInfo := types.APIRequestInfo{Cost: -1}
|
||||
if err := json.Unmarshal([]byte(msg.Text), &apiInfo); err == nil && apiInfo.Cost >= 0 {
|
||||
fmt.Println() // adds a separator between cline message and usage message
|
||||
m.displayMessage(msg, false, false, i)
|
||||
coordinator.CompleteTurn(len(messages))
|
||||
displayedUsage = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// We only want to exit after we've displayed the usage, for the case of seeing completion result
|
||||
if completionChan != nil && foundCompletion && displayedUsage {
|
||||
completionChan <- true
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handlePartialMessageStream handles the SubscribeToPartialMessage stream for streaming assistant text
|
||||
func (m *Manager) handlePartialMessageStream(ctx context.Context, coordinator *StreamCoordinator, errChan chan error) {
|
||||
partialStream, err := m.client.Ui.SubscribeToPartialMessage(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("failed to subscribe to partial messages: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
protoMsg, err := partialStream.Recv()
|
||||
if err != nil {
|
||||
m.renderer.RenderDebug("Partial stream receive error: %v", err)
|
||||
errChan <- fmt.Errorf("failed to receive partial message: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert proto message to our Message struct
|
||||
msg := types.ConvertProtoToMessage(protoMsg)
|
||||
|
||||
// Debug: Log received message (always show for debugging)
|
||||
m.renderer.RenderDebug("Received streaming message: type=%s, partial=%v, text_len=%d",
|
||||
msg.Type, msg.Partial, len(msg.Text))
|
||||
|
||||
// Handle the message with streaming support for de-dupping
|
||||
if err := m.handleStreamingMessage(msg); err != nil {
|
||||
m.renderer.RenderDebug("Error handling streaming message: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleStreamingMessage handles a streaming message
|
||||
func (m *Manager) handleStreamingMessage(msg *types.ClineMessage) error {
|
||||
// Debug: Always log what we're processing
|
||||
m.renderer.RenderDebug("Processing message: timestamp=%d, partial=%v, type=%s, text_preview=%s",
|
||||
msg.Timestamp, msg.Partial, msg.Type, m.truncateText(msg.Text, 50))
|
||||
|
||||
// Use streaming display which handles deduplication internally
|
||||
if err := m.streamingDisplay.HandlePartialMessage(msg); err != nil {
|
||||
m.renderer.RenderDebug("Streaming display failed, using fallback: %v", err)
|
||||
// Fallback to regular display
|
||||
return m.displayMessage(msg, true, false, -1)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// truncateText truncates text for debug display
|
||||
func (m *Manager) truncateText(text string, maxLen int) string {
|
||||
if len(text) <= maxLen {
|
||||
return text
|
||||
}
|
||||
return text[:maxLen] + "..."
|
||||
}
|
||||
|
||||
// displayMessage displays a single message using the handler system
|
||||
func (m *Manager) displayMessage(msg *types.ClineMessage, isLast, isPartial bool, messageIndex int) error {
|
||||
if global.Config.OutputFormat == "json" {
|
||||
return m.outputMessageAsJSON(msg)
|
||||
} else {
|
||||
dc := &handlers.DisplayContext{
|
||||
State: m.state,
|
||||
Renderer: m.renderer,
|
||||
IsLast: isLast,
|
||||
IsPartial: isPartial,
|
||||
MessageIndex: messageIndex,
|
||||
}
|
||||
|
||||
return m.handlerRegistry.Handle(msg, dc)
|
||||
}
|
||||
}
|
||||
|
||||
// outputMessageAsJSON prints a single cline message as json
|
||||
func (m *Manager) outputMessageAsJSON(msg *types.ClineMessage) error {
|
||||
jsonBytes, err := json.MarshalIndent(msg, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal message as JSON: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println(string(jsonBytes))
|
||||
return nil
|
||||
}
|
||||
|
||||
// getCurrentMessageCount gets the current message count without displaying messages
|
||||
func (m *Manager) getCurrentMessageCount(ctx context.Context) (int, error) {
|
||||
state, err := m.client.State.GetLatestState(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get state: %w", err)
|
||||
}
|
||||
|
||||
messages, err := m.extractMessagesFromState(state.StateJson)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to extract messages: %w", err)
|
||||
}
|
||||
|
||||
return len(messages), nil
|
||||
}
|
||||
|
||||
// loadAndDisplayRecentHistory loads and displays recent conversation history and returns the total number of existing messages
|
||||
func (m *Manager) loadAndDisplayRecentHistory(ctx context.Context) (int, error) {
|
||||
// Get the latest state which contains messages
|
||||
state, err := m.client.State.GetLatestState(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get state: %w", err)
|
||||
}
|
||||
|
||||
// Parse the state JSON to extract messages
|
||||
messages, err := m.extractMessagesFromState(state.StateJson)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to extract messages: %w", err)
|
||||
}
|
||||
|
||||
if len(messages) == 0 {
|
||||
fmt.Println("No conversation history found.")
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Show only the last 100 messages by default
|
||||
const maxHistoryMessages = 100
|
||||
totalMessages := len(messages)
|
||||
startIndex := 0
|
||||
|
||||
if totalMessages > maxHistoryMessages {
|
||||
startIndex = totalMessages - maxHistoryMessages
|
||||
fmt.Printf("--- Conversation history (%d of %d messages) ---\n", maxHistoryMessages, totalMessages)
|
||||
} else {
|
||||
fmt.Printf("--- Conversation history (%d messages) ---\n", totalMessages)
|
||||
}
|
||||
|
||||
// Display recent messages
|
||||
for i := startIndex; i < len(messages); i++ {
|
||||
msg := messages[i]
|
||||
|
||||
// Display the message
|
||||
m.displayMessage(msg, false, false, i)
|
||||
}
|
||||
|
||||
// Return the total number of messages in the conversation
|
||||
return totalMessages, nil
|
||||
}
|
||||
|
||||
// extractMessagesFromState parses the state JSON and extracts messages
|
||||
func (m *Manager) extractMessagesFromState(stateJson string) ([]*types.ClineMessage, error) {
|
||||
return types.ExtractMessagesFromStateJSON(stateJson)
|
||||
}
|
||||
|
||||
// GetState returns the current conversation state
|
||||
func (m *Manager) GetState() *types.ConversationState {
|
||||
return m.state
|
||||
}
|
||||
|
||||
// Cleanup cleans up resources
|
||||
func (m *Manager) Cleanup() {
|
||||
// Clean up streaming display resources if needed
|
||||
if m.streamingDisplay != nil {
|
||||
m.streamingDisplay.Cleanup()
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package task
|
||||
|
||||
// StreamCoordinator manages coordination between SubscribeToState and SubscribeToPartialMessage streams
|
||||
type StreamCoordinator struct {
|
||||
conversationTurnStartIndex int // First message index of current turn
|
||||
processedInCurrentTurn map[string]bool // What we've handled in THIS turn
|
||||
}
|
||||
|
||||
// NewStreamCoordinator creates a new stream coordinator
|
||||
func NewStreamCoordinator() *StreamCoordinator {
|
||||
return &StreamCoordinator{
|
||||
conversationTurnStartIndex: 0,
|
||||
processedInCurrentTurn: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
// SetConversationTurnStartIndex sets the starting index for the current conversation turn
|
||||
func (sc *StreamCoordinator) SetConversationTurnStartIndex(index int) {
|
||||
sc.conversationTurnStartIndex = index
|
||||
}
|
||||
|
||||
// GetConversationTurnStartIndex returns the starting index for the current conversation turn
|
||||
func (sc *StreamCoordinator) GetConversationTurnStartIndex() int {
|
||||
return sc.conversationTurnStartIndex
|
||||
}
|
||||
|
||||
// MarkProcessedInCurrentTurn marks an item as processed in the current turn
|
||||
func (sc *StreamCoordinator) MarkProcessedInCurrentTurn(key string) {
|
||||
sc.processedInCurrentTurn[key] = true
|
||||
}
|
||||
|
||||
// IsProcessedInCurrentTurn checks if an item has been processed in the current turn
|
||||
func (sc *StreamCoordinator) IsProcessedInCurrentTurn(key string) bool {
|
||||
return sc.processedInCurrentTurn[key]
|
||||
}
|
||||
|
||||
// CompleteTurn resets the coordinator for the next conversation turn
|
||||
func (sc *StreamCoordinator) CompleteTurn(totalMessages int) {
|
||||
sc.conversationTurnStartIndex = totalMessages
|
||||
sc.processedInCurrentTurn = make(map[string]bool)
|
||||
}
|
||||
@@ -1,321 +0,0 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
"strconv"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
// ClineMessage represents a conversation message in the CLI
|
||||
type ClineMessage struct {
|
||||
Type MessageType `json:"type"`
|
||||
Text string `json:"text"`
|
||||
Timestamp int64 `json:"ts"`
|
||||
Reasoning string `json:"reasoning,omitempty"`
|
||||
Say string `json:"say,omitempty"`
|
||||
Ask string `json:"ask,omitempty"`
|
||||
Partial bool `json:"partial,omitempty"`
|
||||
Images []string `json:"images,omitempty"`
|
||||
Files []string `json:"files,omitempty"`
|
||||
}
|
||||
|
||||
// MessageType represents the type of message
|
||||
type MessageType string
|
||||
|
||||
const (
|
||||
MessageTypeAsk MessageType = "ask"
|
||||
MessageTypeSay MessageType = "say"
|
||||
)
|
||||
|
||||
// AskType represents different types of ASK messages
|
||||
type AskType string
|
||||
|
||||
const (
|
||||
AskTypeFollowup AskType = "followup"
|
||||
AskTypePlanModeRespond AskType = "plan_mode_respond"
|
||||
AskTypeCommand AskType = "command"
|
||||
AskTypeCommandOutput AskType = "command_output"
|
||||
AskTypeCompletionResult AskType = "completion_result"
|
||||
AskTypeTool AskType = "tool"
|
||||
AskTypeAPIReqFailed AskType = "api_req_failed"
|
||||
AskTypeResumeTask AskType = "resume_task"
|
||||
AskTypeResumeCompletedTask AskType = "resume_completed_task"
|
||||
AskTypeMistakeLimitReached AskType = "mistake_limit_reached"
|
||||
AskTypeAutoApprovalMaxReached AskType = "auto_approval_max_req_reached"
|
||||
AskTypeBrowserActionLaunch AskType = "browser_action_launch"
|
||||
AskTypeUseMcpServer AskType = "use_mcp_server"
|
||||
AskTypeNewTask AskType = "new_task"
|
||||
AskTypeCondense AskType = "condense"
|
||||
AskTypeReportBug AskType = "report_bug"
|
||||
)
|
||||
|
||||
// SayType represents different types of SAY messages
|
||||
type SayType string
|
||||
|
||||
const (
|
||||
SayTypeTask SayType = "task"
|
||||
SayTypeError SayType = "error"
|
||||
SayTypeAPIReqStarted SayType = "api_req_started"
|
||||
SayTypeAPIReqFinished SayType = "api_req_finished"
|
||||
SayTypeText SayType = "text"
|
||||
SayTypeReasoning SayType = "reasoning"
|
||||
SayTypeCompletionResult SayType = "completion_result"
|
||||
SayTypeUserFeedback SayType = "user_feedback"
|
||||
SayTypeUserFeedbackDiff SayType = "user_feedback_diff"
|
||||
SayTypeAPIReqRetried SayType = "api_req_retried"
|
||||
SayTypeCommand SayType = "command"
|
||||
SayTypeCommandOutput SayType = "command_output"
|
||||
SayTypeTool SayType = "tool"
|
||||
SayTypeShellIntegrationWarning SayType = "shell_integration_warning"
|
||||
SayTypeBrowserActionLaunch SayType = "browser_action_launch"
|
||||
SayTypeBrowserAction SayType = "browser_action"
|
||||
SayTypeBrowserActionResult SayType = "browser_action_result"
|
||||
SayTypeMcpServerRequestStarted SayType = "mcp_server_request_started"
|
||||
SayTypeMcpServerResponse SayType = "mcp_server_response"
|
||||
SayTypeMcpNotification SayType = "mcp_notification"
|
||||
SayTypeUseMcpServer SayType = "use_mcp_server"
|
||||
SayTypeDiffError SayType = "diff_error"
|
||||
SayTypeDeletedAPIReqs SayType = "deleted_api_reqs"
|
||||
SayTypeClineignoreError SayType = "clineignore_error"
|
||||
SayTypeCheckpointCreated SayType = "checkpoint_created"
|
||||
SayTypeLoadMcpDocumentation SayType = "load_mcp_documentation"
|
||||
SayTypeInfo SayType = "info"
|
||||
SayTypeTaskProgress SayType = "task_progress"
|
||||
)
|
||||
|
||||
// ToolMessage represents a tool-related message
|
||||
type ToolMessage struct {
|
||||
Tool string `json:"tool"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Diff string `json:"diff,omitempty"`
|
||||
Regex string `json:"regex,omitempty"`
|
||||
FilePattern string `json:"filePattern,omitempty"`
|
||||
OperationIsLocatedInWorkspace *bool `json:"operationIsLocatedInWorkspace,omitempty"`
|
||||
}
|
||||
|
||||
// ToolType represents different types of tools
|
||||
type ToolType string
|
||||
|
||||
const (
|
||||
ToolTypeEditedExistingFile ToolType = "editedExistingFile"
|
||||
ToolTypeNewFileCreated ToolType = "newFileCreated"
|
||||
ToolTypeReadFile ToolType = "readFile"
|
||||
ToolTypeListFilesTopLevel ToolType = "listFilesTopLevel"
|
||||
ToolTypeListFilesRecursive ToolType = "listFilesRecursive"
|
||||
ToolTypeListCodeDefinitionNames ToolType = "listCodeDefinitionNames"
|
||||
ToolTypeSearchFiles ToolType = "searchFiles"
|
||||
ToolTypeWebFetch ToolType = "webFetch"
|
||||
ToolTypeSummarizeTask ToolType = "summarizeTask"
|
||||
)
|
||||
|
||||
// AskData represents the parsed structure of an ASK message
|
||||
type AskData struct {
|
||||
Question string `json:"question"`
|
||||
Response string `json:"response"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
// APIRequestInfo represents API request information
|
||||
type APIRequestInfo struct {
|
||||
Request string `json:"request,omitempty"`
|
||||
TokensIn int `json:"tokensIn,omitempty"`
|
||||
TokensOut int `json:"tokensOut,omitempty"`
|
||||
CacheWrites int `json:"cacheWrites,omitempty"`
|
||||
CacheReads int `json:"cacheReads,omitempty"`
|
||||
Cost float64 `json:"cost,omitempty"`
|
||||
CancelReason string `json:"cancelReason,omitempty"`
|
||||
StreamingFailedMessage string `json:"streamingFailedMessage,omitempty"`
|
||||
RetryStatus *APIRequestRetryStatus `json:"retryStatus,omitempty"`
|
||||
}
|
||||
|
||||
// APIRequestRetryStatus represents retry status information
|
||||
type APIRequestRetryStatus struct {
|
||||
Attempt int `json:"attempt"`
|
||||
MaxAttempts int `json:"maxAttempts"`
|
||||
DelaySec int `json:"delaySec"`
|
||||
ErrorSnippet string `json:"errorSnippet,omitempty"`
|
||||
}
|
||||
|
||||
// GetTimestamp returns a formatted timestamp string
|
||||
func (m *ClineMessage) GetTimestamp() string {
|
||||
return time.Unix(m.Timestamp/1000, 0).Format("15:04:05")
|
||||
}
|
||||
|
||||
// IsAsk returns true if this is an ASK message
|
||||
func (m *ClineMessage) IsAsk() bool {
|
||||
return m.Type == MessageTypeAsk
|
||||
}
|
||||
|
||||
// IsSay returns true if this is a SAY message
|
||||
func (m *ClineMessage) IsSay() bool {
|
||||
return m.Type == MessageTypeSay
|
||||
}
|
||||
|
||||
// GetMessageKey returns a unique key for this message based on timestamp
|
||||
func (m *ClineMessage) GetMessageKey() string {
|
||||
return strconv.FormatInt(m.Timestamp, 10)
|
||||
}
|
||||
|
||||
// ExtractMessagesFromStateJSON parses the state JSON and extracts messages
|
||||
func ExtractMessagesFromStateJSON(stateJson string) ([]*ClineMessage, error) {
|
||||
// Parse the state JSON to extract clineMessages
|
||||
var rawState map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(stateJson), &rawState); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse state JSON: %w", err)
|
||||
}
|
||||
|
||||
// Try to extract clineMessages
|
||||
clineMessagesRaw, exists := rawState["clineMessages"]
|
||||
if !exists {
|
||||
return []*ClineMessage{}, nil
|
||||
}
|
||||
|
||||
// Convert to JSON and back to get proper Message structs
|
||||
clineMessagesJson, err := json.Marshal(clineMessagesRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal clineMessages: %w", err)
|
||||
}
|
||||
|
||||
var messages []*ClineMessage
|
||||
if err := json.Unmarshal(clineMessagesJson, &messages); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal clineMessages: %w", err)
|
||||
}
|
||||
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
// ConvertProtoToMessage converts a protobuf ClineMessage to our local Message struct
|
||||
func ConvertProtoToMessage(protoMsg *cline.ClineMessage) *ClineMessage {
|
||||
var msgType MessageType
|
||||
var say, ask string
|
||||
|
||||
// Convert message type
|
||||
switch protoMsg.Type {
|
||||
case cline.ClineMessageType_ASK:
|
||||
msgType = MessageTypeAsk
|
||||
ask = convertProtoAskType(protoMsg.Ask)
|
||||
case cline.ClineMessageType_SAY:
|
||||
msgType = MessageTypeSay
|
||||
say = convertProtoSayType(protoMsg.Say)
|
||||
default:
|
||||
msgType = MessageTypeSay
|
||||
say = "unknown"
|
||||
}
|
||||
|
||||
return &ClineMessage{
|
||||
Type: msgType,
|
||||
Text: protoMsg.Text,
|
||||
Timestamp: protoMsg.Ts,
|
||||
Reasoning: protoMsg.Reasoning,
|
||||
Say: say,
|
||||
Ask: ask,
|
||||
Partial: protoMsg.Partial,
|
||||
}
|
||||
}
|
||||
|
||||
// convertProtoAskType converts protobuf ask type to string
|
||||
func convertProtoAskType(askType cline.ClineAsk) string {
|
||||
switch askType {
|
||||
case cline.ClineAsk_FOLLOWUP:
|
||||
return string(AskTypeFollowup)
|
||||
case cline.ClineAsk_PLAN_MODE_RESPOND:
|
||||
return string(AskTypePlanModeRespond)
|
||||
case cline.ClineAsk_COMMAND:
|
||||
return string(AskTypeCommand)
|
||||
case cline.ClineAsk_COMMAND_OUTPUT:
|
||||
return string(AskTypeCommandOutput)
|
||||
case cline.ClineAsk_COMPLETION_RESULT:
|
||||
return string(AskTypeCompletionResult)
|
||||
case cline.ClineAsk_TOOL:
|
||||
return string(AskTypeTool)
|
||||
case cline.ClineAsk_API_REQ_FAILED:
|
||||
return string(AskTypeAPIReqFailed)
|
||||
case cline.ClineAsk_RESUME_TASK:
|
||||
return string(AskTypeResumeTask)
|
||||
case cline.ClineAsk_RESUME_COMPLETED_TASK:
|
||||
return string(AskTypeResumeCompletedTask)
|
||||
case cline.ClineAsk_MISTAKE_LIMIT_REACHED:
|
||||
return string(AskTypeMistakeLimitReached)
|
||||
case cline.ClineAsk_AUTO_APPROVAL_MAX_REQ_REACHED:
|
||||
return string(AskTypeAutoApprovalMaxReached)
|
||||
case cline.ClineAsk_BROWSER_ACTION_LAUNCH:
|
||||
return string(AskTypeBrowserActionLaunch)
|
||||
case cline.ClineAsk_USE_MCP_SERVER:
|
||||
return string(AskTypeUseMcpServer)
|
||||
case cline.ClineAsk_NEW_TASK:
|
||||
return string(AskTypeNewTask)
|
||||
case cline.ClineAsk_CONDENSE:
|
||||
return string(AskTypeCondense)
|
||||
case cline.ClineAsk_REPORT_BUG:
|
||||
return string(AskTypeReportBug)
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// convertProtoSayType converts protobuf say type to string
|
||||
func convertProtoSayType(sayType cline.ClineSay) string {
|
||||
switch sayType {
|
||||
case cline.ClineSay_TASK:
|
||||
return string(SayTypeTask)
|
||||
case cline.ClineSay_ERROR:
|
||||
return string(SayTypeError)
|
||||
case cline.ClineSay_API_REQ_STARTED:
|
||||
return string(SayTypeAPIReqStarted)
|
||||
case cline.ClineSay_API_REQ_FINISHED:
|
||||
return string(SayTypeAPIReqFinished)
|
||||
case cline.ClineSay_TEXT:
|
||||
return string(SayTypeText)
|
||||
case cline.ClineSay_REASONING:
|
||||
return string(SayTypeReasoning)
|
||||
case cline.ClineSay_COMPLETION_RESULT_SAY:
|
||||
return string(SayTypeCompletionResult)
|
||||
case cline.ClineSay_USER_FEEDBACK:
|
||||
return string(SayTypeUserFeedback)
|
||||
case cline.ClineSay_USER_FEEDBACK_DIFF:
|
||||
return string(SayTypeUserFeedbackDiff)
|
||||
case cline.ClineSay_API_REQ_RETRIED:
|
||||
return string(SayTypeAPIReqRetried)
|
||||
case cline.ClineSay_COMMAND_SAY:
|
||||
return string(SayTypeCommand)
|
||||
case cline.ClineSay_COMMAND_OUTPUT_SAY:
|
||||
return string(SayTypeCommandOutput)
|
||||
case cline.ClineSay_TOOL_SAY:
|
||||
return string(SayTypeTool)
|
||||
case cline.ClineSay_SHELL_INTEGRATION_WARNING:
|
||||
return string(SayTypeShellIntegrationWarning)
|
||||
case cline.ClineSay_BROWSER_ACTION_LAUNCH_SAY:
|
||||
return string(SayTypeBrowserActionLaunch)
|
||||
case cline.ClineSay_BROWSER_ACTION:
|
||||
return string(SayTypeBrowserAction)
|
||||
case cline.ClineSay_BROWSER_ACTION_RESULT:
|
||||
return string(SayTypeBrowserActionResult)
|
||||
case cline.ClineSay_MCP_SERVER_REQUEST_STARTED:
|
||||
return string(SayTypeMcpServerRequestStarted)
|
||||
case cline.ClineSay_MCP_SERVER_RESPONSE:
|
||||
return string(SayTypeMcpServerResponse)
|
||||
case cline.ClineSay_MCP_NOTIFICATION:
|
||||
return string(SayTypeMcpNotification)
|
||||
case cline.ClineSay_USE_MCP_SERVER_SAY:
|
||||
return string(SayTypeUseMcpServer)
|
||||
case cline.ClineSay_DIFF_ERROR:
|
||||
return string(SayTypeDiffError)
|
||||
case cline.ClineSay_DELETED_API_REQS:
|
||||
return string(SayTypeDeletedAPIReqs)
|
||||
case cline.ClineSay_CLINEIGNORE_ERROR:
|
||||
return string(SayTypeClineignoreError)
|
||||
case cline.ClineSay_CHECKPOINT_CREATED:
|
||||
return string(SayTypeCheckpointCreated)
|
||||
case cline.ClineSay_LOAD_MCP_DOCUMENTATION:
|
||||
return string(SayTypeLoadMcpDocumentation)
|
||||
case cline.ClineSay_INFO:
|
||||
return string(SayTypeInfo)
|
||||
case cline.ClineSay_TASK_PROGRESS:
|
||||
return string(SayTypeTaskProgress)
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ConversationState manages the state of the conversation
|
||||
type ConversationState struct {
|
||||
mu sync.RWMutex
|
||||
StreamingMessage *StreamingMessage `json:"streamingMessage,omitempty"`
|
||||
}
|
||||
|
||||
// StreamingMessage manages state for streaming message display
|
||||
type StreamingMessage struct {
|
||||
CurrentKey string `json:"currentKey"`
|
||||
LastText string `json:"lastText"`
|
||||
LastToolMessage string `json:"lastToolMessage,omitempty"`
|
||||
}
|
||||
|
||||
// NewConversationState creates a new conversation state
|
||||
func NewConversationState() *ConversationState {
|
||||
return &ConversationState{
|
||||
StreamingMessage: &StreamingMessage{},
|
||||
}
|
||||
}
|
||||
|
||||
// SetStreamingMessage updates the streaming message state
|
||||
func (cs *ConversationState) SetStreamingMessage(key, text string) {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
cs.StreamingMessage.CurrentKey = key
|
||||
cs.StreamingMessage.LastText = text
|
||||
}
|
||||
|
||||
// GetStreamingMessage returns the current streaming message state
|
||||
func (cs *ConversationState) GetStreamingMessage() *StreamingMessage {
|
||||
cs.mu.RLock()
|
||||
defer cs.mu.RUnlock()
|
||||
return &StreamingMessage{
|
||||
CurrentKey: cs.StreamingMessage.CurrentKey,
|
||||
LastText: cs.StreamingMessage.LastText,
|
||||
LastToolMessage: cs.StreamingMessage.LastToolMessage,
|
||||
}
|
||||
}
|
||||
|
||||
// Clear resets state
|
||||
func (cs *ConversationState) Clear() {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
cs.StreamingMessage = &StreamingMessage{}
|
||||
}
|
||||
|
||||
// ExtensionState represents the server-side extension state structure
|
||||
type ExtensionState struct {
|
||||
CurrentTaskItem *CurrentTaskItem `json:"currentTaskItem,omitempty"`
|
||||
}
|
||||
|
||||
// CurrentTaskItem - minimal struct with just what we need
|
||||
type CurrentTaskItem struct {
|
||||
Id string `json:"id"`
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
// These will be set at build time via ldflags
|
||||
Version = "dev"
|
||||
Commit = "unknown"
|
||||
Date = "unknown"
|
||||
BuiltBy = "unknown"
|
||||
)
|
||||
|
||||
// NewVersionCommand creates the version command
|
||||
func NewVersionCommand() *cobra.Command {
|
||||
var short bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Show version information",
|
||||
Long: `Display version information for the Cline Go host.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if short {
|
||||
fmt.Println(Version)
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("Cline Go Host\n")
|
||||
fmt.Printf("Version: %s\n", Version)
|
||||
fmt.Printf("Commit: %s\n", Commit)
|
||||
fmt.Printf("Built: %s\n", Date)
|
||||
fmt.Printf("Built by: %s\n", BuiltBy)
|
||||
fmt.Printf("Go version: %s\n", runtime.Version())
|
||||
fmt.Printf("OS/Arch: %s/%s\n", runtime.GOOS, runtime.GOARCH)
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&short, "short", false, "show only version number")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
package common
|
||||
|
||||
// WE WILL HAVE TO MIGRATE THIS FROM DATA TO v1 LATER
|
||||
const SETTINGS_SUBFOLDER = "data"
|
||||
|
||||
const DEFAULT_CLINE_CORE_PORT = 50052
|
||||
@@ -1,54 +0,0 @@
|
||||
package common
|
||||
|
||||
// Database query constants for the SQLite locks database
|
||||
const (
|
||||
|
||||
// SelectInstanceLocksSQL selects all instance locks ordered by creation time
|
||||
SelectInstanceLocksSQL = `
|
||||
SELECT id, held_by, lock_type, lock_target, locked_at
|
||||
FROM locks
|
||||
WHERE lock_type = 'instance'
|
||||
ORDER BY locked_at ASC
|
||||
`
|
||||
|
||||
SelectInstanceLockByHolderSQL = `
|
||||
SELECT held_by, lock_target, locked_at
|
||||
FROM locks
|
||||
WHERE held_by = ? AND lock_type = 'instance'
|
||||
`
|
||||
SelectInstanceLockHoldersAscSQL = `
|
||||
SELECT held_by, lock_target, locked_at
|
||||
FROM locks
|
||||
WHERE lock_type = 'instance'
|
||||
ORDER BY locked_at ASC
|
||||
`
|
||||
|
||||
// DeleteInstanceLockSQL deletes an instance lock by address
|
||||
DeleteInstanceLockSQL = `
|
||||
DELETE FROM locks
|
||||
WHERE held_by = ? AND lock_type = 'instance'
|
||||
`
|
||||
|
||||
InsertFileLockSQL = `
|
||||
INSERT INTO locks (held_by, lock_type, lock_target, locked_at)
|
||||
VALUES (?, 'file', ?, ?)
|
||||
`
|
||||
|
||||
// DeleteFileLockSQL deletes a file lock by holder and target
|
||||
DeleteFileLockSQL = `
|
||||
DELETE FROM locks
|
||||
WHERE held_by = ? AND lock_type = 'file' AND lock_target = ?
|
||||
`
|
||||
|
||||
// CountInstanceLockSQL counts instance locks for a given address
|
||||
CountInstanceLockSQL = `
|
||||
SELECT COUNT(*) FROM locks
|
||||
WHERE held_by = ? AND lock_type = 'instance'
|
||||
`
|
||||
|
||||
// InsertInstanceLockSQL inserts or replaces an instance lock
|
||||
InsertInstanceLockSQL = `
|
||||
INSERT OR REPLACE INTO locks (held_by, lock_type, lock_target, locked_at)
|
||||
VALUES (?, 'instance', ?, ?)
|
||||
`
|
||||
)
|
||||
@@ -1,54 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc/health/grpc_health_v1"
|
||||
)
|
||||
|
||||
// CoreInstanceInfo represents a discovered Cline instance
|
||||
// This is the canonical definition used across all CLI packages
|
||||
type CoreInstanceInfo struct {
|
||||
// Full core address including port
|
||||
Address string `json:"address"`
|
||||
// Host bridge service address that core holds (host is ALWAYS running on localhost FYI)
|
||||
HostServiceAddress string `json:"host_port"`
|
||||
Status grpc_health_v1.HealthCheckResponse_ServingStatus `json:"status"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
ProcessPID int `json:"process_pid,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
}
|
||||
|
||||
func (c *CoreInstanceInfo) CorePort() int {
|
||||
_, port, _ := ParseHostPort(c.Address)
|
||||
return port
|
||||
}
|
||||
|
||||
func (c *CoreInstanceInfo) HostPort() int {
|
||||
_, port, _ := ParseHostPort(c.HostServiceAddress)
|
||||
return port
|
||||
}
|
||||
|
||||
func (c *CoreInstanceInfo) StatusString() string {
|
||||
return c.Status.String()
|
||||
}
|
||||
|
||||
// LockRow represents a row in the locks table
|
||||
type LockRow struct {
|
||||
ID int64 `json:"id"`
|
||||
HeldBy string `json:"held_by"`
|
||||
LockType string `json:"lock_type"`
|
||||
LockTarget string `json:"lock_target"`
|
||||
LockedAt int64 `json:"locked_at"`
|
||||
}
|
||||
|
||||
// InstancesOutput represents the JSON output format for instance listing
|
||||
type InstancesOutput struct {
|
||||
DefaultInstance string `json:"default_instance"`
|
||||
CoreInstances []CoreInstanceInfo `json:"instances"`
|
||||
}
|
||||
|
||||
type DefaultCoreInstance struct {
|
||||
Address string `json:"default_instance"`
|
||||
LastUpdated string `json:"last_updated"`
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/health/grpc_health_v1"
|
||||
)
|
||||
|
||||
// ParseHostPort parses a host:port address and returns the host and port separately
|
||||
func ParseHostPort(address string) (string, int, error) {
|
||||
host, portStr, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return host, port, nil
|
||||
}
|
||||
|
||||
// IsLocalAddress checks if the given host is a local/loopback address
|
||||
// Supports both IPv4 (localhost, 127.0.0.1) and IPv6 (::1) addresses
|
||||
func IsLocalAddress(host string) bool {
|
||||
// Handle common localhost names
|
||||
if host == "localhost" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Parse as IP and check if it's a loopback
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
return ip.IsLoopback()
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// PerformHealthCheck performs a gRPC health check on the given address
|
||||
// Will return UNKNOWN if the service is unreachable (error)
|
||||
func PerformHealthCheck(ctx context.Context, address string) (grpc_health_v1.HealthCheckResponse_ServingStatus, error) {
|
||||
conn, err := grpc.DialContext(ctx, address, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
return grpc_health_v1.HealthCheckResponse_UNKNOWN, err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
healthClient := grpc_health_v1.NewHealthClient(conn)
|
||||
resp, err := healthClient.Check(ctx, &grpc_health_v1.HealthCheckRequest{})
|
||||
if err != nil {
|
||||
return grpc_health_v1.HealthCheckResponse_UNKNOWN, err
|
||||
}
|
||||
|
||||
return resp.Status, nil
|
||||
}
|
||||
|
||||
// It's healthy if we can reach it and it responds with SERVING
|
||||
func IsInstanceHealthy(ctx context.Context, address string) bool {
|
||||
status, err := PerformHealthCheck(ctx, address)
|
||||
return err == nil && status == grpc_health_v1.HealthCheckResponse_SERVING
|
||||
}
|
||||
|
||||
// It's (likely) our instance if we can reach it and it responds to health checks
|
||||
func IsInstanceOurs(ctx context.Context, address string) bool {
|
||||
_, err := PerformHealthCheck(ctx, address)
|
||||
return err != nil
|
||||
}
|
||||
|
||||
// (unreachable or not serving)
|
||||
func IsInstanceStale(ctx context.Context, address string) (grpc_health_v1.HealthCheckResponse_ServingStatus, bool, error) {
|
||||
status, err := PerformHealthCheck(ctx, address)
|
||||
isStale := err != nil || status != grpc_health_v1.HealthCheckResponse_SERVING
|
||||
return status, isStale, err
|
||||
}
|
||||
|
||||
// IsPortAvailable checks if a port is available for binding
|
||||
func IsPortAvailable(port int) bool {
|
||||
address := fmt.Sprintf("localhost:%d", port)
|
||||
listener, err := net.Listen("tcp", address)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
listener.Close()
|
||||
return true
|
||||
}
|
||||
|
||||
// FindAvailablePortPair finds two available ports by letting the OS allocate them
|
||||
func FindAvailablePortPair() (corePort, hostPort int, err error) {
|
||||
coreListener, err := net.Listen("tcp", ":0")
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
defer coreListener.Close()
|
||||
|
||||
hostListener, err := net.Listen("tcp", ":0")
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
defer hostListener.Close()
|
||||
|
||||
corePort = coreListener.Addr().(*net.TCPAddr).Port
|
||||
hostPort = hostListener.Addr().(*net.TCPAddr).Port
|
||||
|
||||
return corePort, hostPort, nil
|
||||
}
|
||||
|
||||
// NormalizeAddressForGRPC converts address to host:port for grpc client with proper normalization
|
||||
func NormalizeAddressForGRPC(address string) (string, error) {
|
||||
host, port, err := ParseHostPort(address)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Normalize local addresses to localhost for gRPC compatibility
|
||||
if IsLocalAddress(host) {
|
||||
return fmt.Sprintf("localhost:%d", port), nil
|
||||
}
|
||||
|
||||
return address, nil
|
||||
}
|
||||
|
||||
// RetryOperation performs an operation with retry logic
|
||||
func RetryOperation(maxRetries int, timeoutPerAttempt time.Duration, operation func() error) error {
|
||||
var lastErr error
|
||||
|
||||
for attempt := 1; attempt <= maxRetries; attempt++ {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeoutPerAttempt)
|
||||
|
||||
// Create a channel to capture the operation result
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- operation()
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
cancel()
|
||||
if err == nil {
|
||||
return nil // Success
|
||||
}
|
||||
lastErr = err
|
||||
case <-ctx.Done():
|
||||
cancel()
|
||||
lastErr = ctx.Err()
|
||||
}
|
||||
|
||||
// Add delay between attempts (except for the last one)
|
||||
if attempt < maxRetries {
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("operation failed after %d attempts: %w", maxRetries, lastErr)
|
||||
}
|
||||
@@ -1,351 +0,0 @@
|
||||
package hostbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
proto "github.com/cline/grpc-go/host"
|
||||
)
|
||||
|
||||
// diffSession represents an in-memory diff editing session
|
||||
type diffSession struct {
|
||||
originalPath string // File path from OpenDiff request
|
||||
originalContent []byte // Original file content (for comparison)
|
||||
currentContent []byte // Current modified content
|
||||
lines []string // Current content split into lines
|
||||
encoding string // File encoding (default: utf8)
|
||||
}
|
||||
|
||||
// DiffService implements the proto.DiffServiceServer interface
|
||||
type DiffService struct {
|
||||
proto.UnimplementedDiffServiceServer
|
||||
verbose bool
|
||||
sessions *sync.Map // thread-safe: diffId -> *diffSession
|
||||
counter *int64 // atomic counter for unique IDs
|
||||
}
|
||||
|
||||
// NewDiffService creates a new DiffService
|
||||
func NewDiffService(verbose bool) *DiffService {
|
||||
counter := int64(0)
|
||||
return &DiffService{
|
||||
verbose: verbose,
|
||||
sessions: &sync.Map{},
|
||||
counter: &counter,
|
||||
}
|
||||
}
|
||||
|
||||
// generateDiffID creates a unique diff ID
|
||||
func (s *DiffService) generateDiffID() string {
|
||||
id := atomic.AddInt64(s.counter, 1)
|
||||
return fmt.Sprintf("diff_%d_%d", os.Getpid(), id)
|
||||
}
|
||||
|
||||
// splitLines splits content into lines, preserving line ending information
|
||||
func splitLines(content string) []string {
|
||||
if content == "" {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
lines := []string{}
|
||||
current := ""
|
||||
|
||||
for _, char := range content {
|
||||
if char == '\n' {
|
||||
lines = append(lines, current)
|
||||
current = ""
|
||||
} else if char != '\r' { // Skip \r characters, handle \r\n as \n
|
||||
current += string(char)
|
||||
}
|
||||
}
|
||||
|
||||
// Add the last line if it doesn't end with newline
|
||||
if current != "" {
|
||||
lines = append(lines, current)
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
// joinLines joins lines back into content with newlines
|
||||
func joinLines(lines []string) string {
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// OpenDiff opens a diff view for the specified file
|
||||
func (s *DiffService) OpenDiff(ctx context.Context, req *proto.OpenDiffRequest) (*proto.OpenDiffResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("OpenDiff called for path: %s", req.GetPath())
|
||||
}
|
||||
|
||||
diffID := s.generateDiffID()
|
||||
|
||||
var originalContent []byte
|
||||
|
||||
// Check if file exists and read original content
|
||||
if req.GetPath() != "" {
|
||||
if _, err := os.Stat(req.GetPath()); err == nil {
|
||||
// File exists, read its content
|
||||
var readErr error
|
||||
originalContent, readErr = ioutil.ReadFile(req.GetPath())
|
||||
if readErr != nil {
|
||||
return nil, fmt.Errorf("failed to read original file: %w", readErr)
|
||||
}
|
||||
} else {
|
||||
// File doesn't exist, use empty content
|
||||
originalContent = []byte{}
|
||||
}
|
||||
}
|
||||
|
||||
// Use provided content as the initial current content
|
||||
currentContent := []byte(req.GetContent())
|
||||
|
||||
// Create the diff session
|
||||
session := &diffSession{
|
||||
originalPath: req.GetPath(),
|
||||
originalContent: originalContent,
|
||||
currentContent: currentContent,
|
||||
lines: splitLines(req.GetContent()),
|
||||
encoding: "utf8", // Default encoding
|
||||
}
|
||||
|
||||
// Store the session
|
||||
s.sessions.Store(diffID, session)
|
||||
|
||||
if s.verbose {
|
||||
log.Printf("Created diff session: %s (original: %d bytes, current: %d bytes)",
|
||||
diffID, len(originalContent), len(currentContent))
|
||||
}
|
||||
|
||||
return &proto.OpenDiffResponse{
|
||||
DiffId: &diffID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetDocumentText returns the current content of the diff document
|
||||
func (s *DiffService) GetDocumentText(ctx context.Context, req *proto.GetDocumentTextRequest) (*proto.GetDocumentTextResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("GetDocumentText called for diff ID: %s", req.GetDiffId())
|
||||
}
|
||||
|
||||
sessionInterface, exists := s.sessions.Load(req.GetDiffId())
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("diff session not found: %s", req.GetDiffId())
|
||||
}
|
||||
|
||||
session := sessionInterface.(*diffSession)
|
||||
content := string(session.currentContent)
|
||||
|
||||
return &proto.GetDocumentTextResponse{
|
||||
Content: &content,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ReplaceText replaces text in the diff document using line-based operations
|
||||
func (s *DiffService) ReplaceText(ctx context.Context, req *proto.ReplaceTextRequest) (*proto.ReplaceTextResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("ReplaceText called for diff ID: %s, lines %d-%d",
|
||||
req.GetDiffId(), req.GetStartLine(), req.GetEndLine())
|
||||
}
|
||||
|
||||
sessionInterface, exists := s.sessions.Load(req.GetDiffId())
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("diff session not found: %s", req.GetDiffId())
|
||||
}
|
||||
|
||||
session := sessionInterface.(*diffSession)
|
||||
|
||||
startLine := int(req.GetStartLine())
|
||||
endLine := int(req.GetEndLine())
|
||||
newContent := req.GetContent()
|
||||
|
||||
// Validate line ranges
|
||||
if startLine < 0 {
|
||||
startLine = 0
|
||||
}
|
||||
if endLine < startLine {
|
||||
endLine = startLine
|
||||
}
|
||||
|
||||
// Split new content into lines
|
||||
newLines := splitLines(newContent)
|
||||
|
||||
// Ensure we have enough lines in the current content
|
||||
for len(session.lines) < endLine {
|
||||
session.lines = append(session.lines, "")
|
||||
}
|
||||
|
||||
// Replace the specified line range
|
||||
if endLine > len(session.lines) {
|
||||
// Extending beyond current content - append new lines
|
||||
session.lines = append(session.lines[:startLine], newLines...)
|
||||
} else {
|
||||
// Replace within existing content
|
||||
result := make([]string, 0, len(session.lines)-endLine+startLine+len(newLines))
|
||||
result = append(result, session.lines[:startLine]...)
|
||||
result = append(result, newLines...)
|
||||
result = append(result, session.lines[endLine:]...)
|
||||
session.lines = result
|
||||
}
|
||||
|
||||
// Update current content
|
||||
session.currentContent = []byte(joinLines(session.lines))
|
||||
|
||||
// Store the updated session
|
||||
s.sessions.Store(req.GetDiffId(), session)
|
||||
|
||||
if s.verbose {
|
||||
log.Printf("Updated diff session %s: %d lines, %d bytes",
|
||||
req.GetDiffId(), len(session.lines), len(session.currentContent))
|
||||
}
|
||||
|
||||
return &proto.ReplaceTextResponse{}, nil
|
||||
}
|
||||
|
||||
// ScrollDiff scrolls the diff view to a specific line (no-op for CLI)
|
||||
func (s *DiffService) ScrollDiff(ctx context.Context, req *proto.ScrollDiffRequest) (*proto.ScrollDiffResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("ScrollDiff called for diff ID: %s, line: %d", req.GetDiffId(), req.GetLine())
|
||||
}
|
||||
|
||||
// Verify session exists
|
||||
if _, exists := s.sessions.Load(req.GetDiffId()); !exists {
|
||||
return nil, fmt.Errorf("diff session not found: %s", req.GetDiffId())
|
||||
}
|
||||
|
||||
// In a CLI implementation, scrolling is a no-op
|
||||
// In a GUI implementation, this would scroll the view to the specified line
|
||||
return &proto.ScrollDiffResponse{}, nil
|
||||
}
|
||||
|
||||
// TruncateDocument truncates the diff document at the specified line
|
||||
func (s *DiffService) TruncateDocument(ctx context.Context, req *proto.TruncateDocumentRequest) (*proto.TruncateDocumentResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("TruncateDocument called for diff ID: %s, end line: %d", req.GetDiffId(), req.GetEndLine())
|
||||
}
|
||||
|
||||
sessionInterface, exists := s.sessions.Load(req.GetDiffId())
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("diff session not found: %s", req.GetDiffId())
|
||||
}
|
||||
|
||||
session := sessionInterface.(*diffSession)
|
||||
endLine := int(req.GetEndLine())
|
||||
|
||||
// Truncate lines at the specified position
|
||||
if endLine >= 0 && endLine < len(session.lines) {
|
||||
session.lines = session.lines[:endLine]
|
||||
session.currentContent = []byte(joinLines(session.lines))
|
||||
|
||||
// Store the updated session
|
||||
s.sessions.Store(req.GetDiffId(), session)
|
||||
|
||||
if s.verbose {
|
||||
log.Printf("Truncated diff session %s to %d lines", req.GetDiffId(), len(session.lines))
|
||||
}
|
||||
}
|
||||
|
||||
return &proto.TruncateDocumentResponse{}, nil
|
||||
}
|
||||
|
||||
// SaveDocument saves the diff document to the original file
|
||||
func (s *DiffService) SaveDocument(ctx context.Context, req *proto.SaveDocumentRequest) (*proto.SaveDocumentResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("SaveDocument called for diff ID: %s", req.GetDiffId())
|
||||
}
|
||||
|
||||
sessionInterface, exists := s.sessions.Load(req.GetDiffId())
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("diff session not found: %s", req.GetDiffId())
|
||||
}
|
||||
|
||||
session := sessionInterface.(*diffSession)
|
||||
|
||||
if session.originalPath == "" {
|
||||
return nil, fmt.Errorf("no file path specified for diff session: %s", req.GetDiffId())
|
||||
}
|
||||
|
||||
// Create parent directories if they don't exist
|
||||
dir := filepath.Dir(session.originalPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create directories: %w", err)
|
||||
}
|
||||
|
||||
// Write the current content to the original file
|
||||
if err := ioutil.WriteFile(session.originalPath, session.currentContent, 0644); err != nil {
|
||||
return nil, fmt.Errorf("failed to save file: %w", err)
|
||||
}
|
||||
|
||||
if s.verbose {
|
||||
log.Printf("Saved diff session %s to file: %s (%d bytes)",
|
||||
req.GetDiffId(), session.originalPath, len(session.currentContent))
|
||||
}
|
||||
|
||||
return &proto.SaveDocumentResponse{}, nil
|
||||
}
|
||||
|
||||
// CloseAllDiffs closes all diff views and cleans up all sessions
|
||||
func (s *DiffService) CloseAllDiffs(ctx context.Context, req *proto.CloseAllDiffsRequest) (*proto.CloseAllDiffsResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("CloseAllDiffs called")
|
||||
}
|
||||
|
||||
var count int64
|
||||
|
||||
s.sessions.Range(func(key, value any) bool {
|
||||
// Optional: attempt to close if the value supports it
|
||||
if c, ok := value.(interface{ Close() error }); ok {
|
||||
_ = c.Close() // best-effort; ignore error
|
||||
}
|
||||
|
||||
s.sessions.Delete(key)
|
||||
atomic.AddInt64(&count, 1)
|
||||
return true
|
||||
})
|
||||
|
||||
if s.verbose {
|
||||
log.Printf("Closed %d diff sessions", count)
|
||||
}
|
||||
|
||||
return &proto.CloseAllDiffsResponse{}, nil
|
||||
}
|
||||
|
||||
// OpenMultiFileDiff displays a diff view comparing before/after states for multiple files
|
||||
func (s *DiffService) OpenMultiFileDiff(ctx context.Context, req *proto.OpenMultiFileDiffRequest) (*proto.OpenMultiFileDiffResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("OpenMultiFileDiff called with title: %s, %d files", req.GetTitle(), len(req.GetDiffs()))
|
||||
}
|
||||
|
||||
// In a CLI implementation, we could display the diffs to console
|
||||
// For now, we'll just log the information
|
||||
title := req.GetTitle()
|
||||
if title == "" {
|
||||
title = "Multi-file diff"
|
||||
}
|
||||
|
||||
if s.verbose {
|
||||
log.Printf("=== %s ===", title)
|
||||
for i, diff := range req.GetDiffs() {
|
||||
log.Printf("File %d: %s", i+1, diff.GetFilePath())
|
||||
log.Printf(" Left content: %d bytes", len(diff.GetLeftContent()))
|
||||
log.Printf(" Right content: %d bytes", len(diff.GetRightContent()))
|
||||
}
|
||||
}
|
||||
|
||||
// In a more sophisticated CLI implementation, we could:
|
||||
// 1. Use a diff library to generate unified diffs
|
||||
// 2. Display them with colors
|
||||
// 3. Allow navigation between files
|
||||
// For now, this is a no-op that just acknowledges the request
|
||||
|
||||
return &proto.OpenMultiFileDiffResponse{}, nil
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package hostbridge
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/cline/grpc-go/host"
|
||||
)
|
||||
|
||||
// WatchService implements the host.WatchServiceServer interface
|
||||
type WatchService struct {
|
||||
host.UnimplementedWatchServiceServer
|
||||
coreAddress string
|
||||
verbose bool
|
||||
}
|
||||
|
||||
// NewWatchService creates a new WatchService
|
||||
func NewWatchService(coreAddress string, verbose bool) *WatchService {
|
||||
return &WatchService{
|
||||
coreAddress: coreAddress,
|
||||
verbose: verbose,
|
||||
}
|
||||
}
|
||||
|
||||
// SubscribeToFile subscribes to file change notifications
|
||||
func (s *WatchService) SubscribeToFile(req *host.SubscribeToFileRequest, stream host.WatchService_SubscribeToFileServer) error {
|
||||
if s.verbose {
|
||||
log.Printf("SubscribeToFile called for path: %s", req.GetPath())
|
||||
}
|
||||
|
||||
// For console implementation, we'll just log that we would watch the file
|
||||
// In a real implementation, we'd use fsnotify or similar to watch file changes
|
||||
log.Printf("[Cline] Would watch file: %s", req.GetPath())
|
||||
|
||||
// Keep the stream open but don't send any events for now
|
||||
// In a real implementation, we'd send FileChangeEvent messages when files change
|
||||
<-stream.Context().Done()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package hostbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
proto "github.com/cline/grpc-go/host"
|
||||
)
|
||||
|
||||
// WindowService implements the proto.WindowServiceServer interface
|
||||
type WindowService struct {
|
||||
proto.UnimplementedWindowServiceServer
|
||||
coreAddress string
|
||||
verbose bool
|
||||
}
|
||||
|
||||
// NewWindowService creates a new WindowService
|
||||
func NewWindowService(coreAddress string, verbose bool) *WindowService {
|
||||
return &WindowService{
|
||||
coreAddress: coreAddress,
|
||||
verbose: verbose,
|
||||
}
|
||||
}
|
||||
|
||||
// ShowTextDocument opens a text document for viewing/editing
|
||||
func (s *WindowService) ShowTextDocument(ctx context.Context, req *proto.ShowTextDocumentRequest) (*proto.TextEditorInfo, error) {
|
||||
if s.verbose {
|
||||
log.Printf("ShowTextDocument called for path: %s", req.GetPath())
|
||||
}
|
||||
|
||||
// For console implementation, we'll just log that we would open the document
|
||||
fmt.Printf("[Cline] Would open document: %s\n", req.GetPath())
|
||||
|
||||
return &proto.TextEditorInfo{
|
||||
DocumentPath: req.GetPath(),
|
||||
IsActive: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ShowOpenDialogue shows a file open dialog
|
||||
func (s *WindowService) ShowOpenDialogue(ctx context.Context, req *proto.ShowOpenDialogueRequest) (*proto.SelectedResources, error) {
|
||||
if s.verbose {
|
||||
log.Printf("ShowOpenDialogue called")
|
||||
}
|
||||
|
||||
// For console implementation, return empty list (user cancelled)
|
||||
return &proto.SelectedResources{
|
||||
Paths: []string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ShowMessage displays a message to the user
|
||||
func (s *WindowService) ShowMessage(ctx context.Context, req *proto.ShowMessageRequest) (*proto.SelectedResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("ShowMessage called: %s", req.GetMessage())
|
||||
}
|
||||
|
||||
// Display message to console
|
||||
fmt.Printf("[Cline] %s\n", req.GetMessage())
|
||||
|
||||
return &proto.SelectedResponse{}, nil
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package hostbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/cline/grpc-go/host"
|
||||
)
|
||||
|
||||
// WorkspaceService implements the host.WorkspaceServiceServer interface
|
||||
type WorkspaceService struct {
|
||||
host.UnimplementedWorkspaceServiceServer
|
||||
coreAddress string
|
||||
verbose bool
|
||||
}
|
||||
|
||||
// NewWorkspaceService creates a new WorkspaceService
|
||||
func NewWorkspaceService(coreAddress string, verbose bool) *WorkspaceService {
|
||||
return &WorkspaceService{
|
||||
coreAddress: coreAddress,
|
||||
verbose: verbose,
|
||||
}
|
||||
}
|
||||
|
||||
// GetWorkspacePaths returns the workspace directory paths
|
||||
func (s *WorkspaceService) GetWorkspacePaths(ctx context.Context, req *host.GetWorkspacePathsRequest) (*host.GetWorkspacePathsResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("GetWorkspacePaths called")
|
||||
}
|
||||
|
||||
// Get current working directory as the workspace
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &host.GetWorkspacePathsResponse{
|
||||
Paths: []string{cwd},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SaveOpenDocumentIfDirty saves an open document if it has unsaved changes
|
||||
func (s *WorkspaceService) SaveOpenDocumentIfDirty(ctx context.Context, req *host.SaveOpenDocumentIfDirtyRequest) (*host.SaveOpenDocumentIfDirtyResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("SaveOpenDocumentIfDirty called for path: %s", req.GetPath())
|
||||
}
|
||||
|
||||
// For console implementation, we'll assume the document is already saved
|
||||
// In a real implementation, we'd check if the file has unsaved changes
|
||||
return &host.SaveOpenDocumentIfDirtyResponse{
|
||||
WasSaved: false, // Assume no changes to save
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetDiagnostics returns diagnostic information for a file
|
||||
func (s *WorkspaceService) GetDiagnostics(ctx context.Context, req *host.GetDiagnosticsRequest) (*host.GetDiagnosticsResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("GetDiagnostics called for path: %s", req.GetPath())
|
||||
}
|
||||
|
||||
// For console implementation, return empty diagnostics
|
||||
return &host.GetDiagnosticsResponse{
|
||||
Diagnostics: []*host.Diagnostic{},
|
||||
}, nil
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
package hostbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/cline/grpc-go/cline"
|
||||
"github.com/cline/grpc-go/host"
|
||||
)
|
||||
|
||||
// Global shutdown channel - simple approach
|
||||
var globalShutdownCh chan struct{}
|
||||
|
||||
func init() {
|
||||
globalShutdownCh = make(chan struct{})
|
||||
}
|
||||
|
||||
// EnvService implements the host.EnvServiceServer interface
|
||||
type EnvService struct {
|
||||
host.UnimplementedEnvServiceServer
|
||||
verbose bool
|
||||
}
|
||||
|
||||
// NewEnvService creates a new EnvService
|
||||
func NewEnvService(verbose bool) *EnvService {
|
||||
return &EnvService{
|
||||
verbose: verbose,
|
||||
}
|
||||
}
|
||||
|
||||
// ClipboardWriteText writes text to the system clipboard
|
||||
func (s *EnvService) ClipboardWriteText(ctx context.Context, req *cline.StringRequest) (*cline.Empty, error) {
|
||||
if s.verbose {
|
||||
log.Printf("ClipboardWriteText called with: %s", req.GetValue())
|
||||
}
|
||||
|
||||
// TODO: Implement actual clipboard functionality
|
||||
// For now, just return success
|
||||
return &cline.Empty{}, nil
|
||||
}
|
||||
|
||||
// ClipboardReadText reads text from the system clipboard
|
||||
func (s *EnvService) ClipboardReadText(ctx context.Context, req *cline.EmptyRequest) (*cline.String, error) {
|
||||
if s.verbose {
|
||||
log.Printf("ClipboardReadText called")
|
||||
}
|
||||
|
||||
// TODO: Implement actual clipboard functionality
|
||||
// For now, return empty string
|
||||
return &cline.String{
|
||||
Value: "",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetMachineId returns a stable machine identifier for telemetry distinctId purposes
|
||||
func (s *EnvService) GetMachineId(ctx context.Context, req *cline.EmptyRequest) (*cline.String, error) {
|
||||
if s.verbose {
|
||||
log.Printf("GetMachineId called")
|
||||
}
|
||||
|
||||
// TODO: Implement actual machine ID functionality
|
||||
// For now, return empty string
|
||||
return &cline.String{
|
||||
Value: "",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetHostVersion returns the host platform name and version
|
||||
func (s *EnvService) GetHostVersion(ctx context.Context, req *cline.EmptyRequest) (*host.GetHostVersionResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("GetHostVersion called")
|
||||
}
|
||||
|
||||
// TODO: Implement actual host version functionality
|
||||
// For now, return empty response
|
||||
return &host.GetHostVersionResponse{}, nil
|
||||
}
|
||||
|
||||
// Shutdown initiates a graceful shutdown of the host bridge service
|
||||
func (s *EnvService) Shutdown(ctx context.Context, req *cline.EmptyRequest) (*cline.Empty, error) {
|
||||
if s.verbose {
|
||||
log.Printf("Shutdown requested via RPC")
|
||||
}
|
||||
|
||||
// Trigger global shutdown signal
|
||||
select {
|
||||
case globalShutdownCh <- struct{}{}:
|
||||
if s.verbose {
|
||||
log.Printf("Shutdown signal sent successfully")
|
||||
}
|
||||
default:
|
||||
if s.verbose {
|
||||
log.Printf("Shutdown signal already pending")
|
||||
}
|
||||
}
|
||||
|
||||
return &cline.Empty{}, nil
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
package hostbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
|
||||
"github.com/cline/grpc-go/host"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/health"
|
||||
"google.golang.org/grpc/health/grpc_health_v1"
|
||||
)
|
||||
|
||||
// GrpcServer provides gRPC hostbridge functionality
|
||||
type GrpcServer struct {
|
||||
port int
|
||||
verbose bool
|
||||
server *grpc.Server
|
||||
shutdownCh chan struct{}
|
||||
}
|
||||
|
||||
// NewGrpcServer creates a new GrpcServer
|
||||
func NewGrpcServer(port int, verbose bool) *GrpcServer {
|
||||
return &GrpcServer{
|
||||
port: port,
|
||||
verbose: verbose,
|
||||
shutdownCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start starts the gRPC hostbridge server
|
||||
func (s *GrpcServer) Start(ctx context.Context) error {
|
||||
if s.verbose {
|
||||
log.Printf("Starting gRPC hostbridge server on port %d", s.port)
|
||||
}
|
||||
|
||||
// Create listener
|
||||
lis, err := net.Listen("tcp", fmt.Sprintf(":%d", s.port))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to listen on port %d: %w", s.port, err)
|
||||
}
|
||||
|
||||
// Create gRPC server
|
||||
s.server = grpc.NewServer()
|
||||
|
||||
// Register health service
|
||||
healthServer := health.NewServer()
|
||||
healthServer.SetServingStatus("", grpc_health_v1.HealthCheckResponse_SERVING)
|
||||
grpc_health_v1.RegisterHealthServer(s.server, healthServer)
|
||||
|
||||
// Register services
|
||||
workspaceService := NewSimpleWorkspaceService(s.verbose)
|
||||
host.RegisterWorkspaceServiceServer(s.server, workspaceService)
|
||||
|
||||
windowService := NewWindowService(s.verbose)
|
||||
host.RegisterWindowServiceServer(s.server, windowService)
|
||||
|
||||
diffService := NewDiffService(s.verbose)
|
||||
host.RegisterDiffServiceServer(s.server, diffService)
|
||||
|
||||
envService := NewEnvService(s.verbose)
|
||||
host.RegisterEnvServiceServer(s.server, envService)
|
||||
|
||||
if s.verbose {
|
||||
log.Printf("Registered HealthService")
|
||||
log.Printf("Registered WorkspaceService")
|
||||
log.Printf("Registered WindowService")
|
||||
log.Printf("Registered DiffService")
|
||||
log.Printf("Registered EnvService")
|
||||
}
|
||||
|
||||
// Start server in goroutine
|
||||
go func() {
|
||||
if s.verbose {
|
||||
log.Printf("gRPC server listening on :%d", s.port)
|
||||
}
|
||||
if err := s.server.Serve(lis); err != nil {
|
||||
log.Printf("gRPC server error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for context cancellation or global shutdown signal
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if s.verbose {
|
||||
log.Println("Context cancelled, shutting down gRPC hostbridge server...")
|
||||
}
|
||||
case <-globalShutdownCh:
|
||||
if s.verbose {
|
||||
log.Println("Shutdown requested via RPC, shutting down gRPC hostbridge server...")
|
||||
}
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
s.server.GracefulStop()
|
||||
|
||||
if s.verbose {
|
||||
log.Println("gRPC hostbridge server stopped")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TriggerShutdown triggers a graceful shutdown of the server
|
||||
func (s *GrpcServer) TriggerShutdown() {
|
||||
select {
|
||||
case s.shutdownCh <- struct{}{}:
|
||||
// Shutdown signal sent
|
||||
default:
|
||||
// Channel already has a signal or is closed
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package hostbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
)
|
||||
|
||||
// Simple implementations that don't rely on proto files for now
|
||||
// This allows us to test the basic hostbridge structure
|
||||
|
||||
// SimpleService provides basic hostbridge functionality
|
||||
type SimpleService struct {
|
||||
coreAddress string
|
||||
verbose bool
|
||||
}
|
||||
|
||||
// NewSimpleService creates a new SimpleService
|
||||
func NewSimpleService(coreAddress string, verbose bool) *SimpleService {
|
||||
return &SimpleService{
|
||||
coreAddress: coreAddress,
|
||||
verbose: verbose,
|
||||
}
|
||||
}
|
||||
|
||||
// Start starts the simple hostbridge service
|
||||
func (s *SimpleService) Start(ctx context.Context) error {
|
||||
if s.verbose {
|
||||
log.Printf("Starting simple hostbridge service (connecting to core at %s)", s.coreAddress)
|
||||
}
|
||||
|
||||
// For now, just log that we're running
|
||||
fmt.Printf("[Cline Host Bridge] Service started on core address: %s\n", s.coreAddress)
|
||||
|
||||
// Keep running until context is cancelled
|
||||
<-ctx.Done()
|
||||
|
||||
if s.verbose {
|
||||
log.Println("Simple hostbridge service stopped")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
package hostbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/cline/grpc-go/cline"
|
||||
"github.com/cline/grpc-go/host"
|
||||
)
|
||||
|
||||
// SimpleWorkspaceService implements a basic workspace service without complex dependencies
|
||||
type SimpleWorkspaceService struct {
|
||||
host.UnimplementedWorkspaceServiceServer
|
||||
verbose bool
|
||||
}
|
||||
|
||||
// NewSimpleWorkspaceService creates a new SimpleWorkspaceService
|
||||
func NewSimpleWorkspaceService(verbose bool) *SimpleWorkspaceService {
|
||||
return &SimpleWorkspaceService{
|
||||
verbose: verbose,
|
||||
}
|
||||
}
|
||||
|
||||
// GetWorkspacePaths returns the workspace directory paths
|
||||
func (s *SimpleWorkspaceService) GetWorkspacePaths(ctx context.Context, req *host.GetWorkspacePathsRequest) (*host.GetWorkspacePathsResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("GetWorkspacePaths called")
|
||||
}
|
||||
|
||||
// Get current working directory as the workspace
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &host.GetWorkspacePathsResponse{
|
||||
Paths: []string{cwd},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SaveOpenDocumentIfDirty saves an open document if it has unsaved changes
|
||||
func (s *SimpleWorkspaceService) SaveOpenDocumentIfDirty(ctx context.Context, req *host.SaveOpenDocumentIfDirtyRequest) (*host.SaveOpenDocumentIfDirtyResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("SaveOpenDocumentIfDirty called for path: %s", req.GetFilePath())
|
||||
}
|
||||
|
||||
// For console implementation, we'll assume the document is already saved
|
||||
wasSaved := false
|
||||
return &host.SaveOpenDocumentIfDirtyResponse{
|
||||
WasSaved: &wasSaved,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetDiagnostics returns diagnostic information for a file - simplified version
|
||||
func (s *SimpleWorkspaceService) GetDiagnostics(ctx context.Context, req *host.GetDiagnosticsRequest) (*host.GetDiagnosticsResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("GetDiagnostics called")
|
||||
}
|
||||
|
||||
// For console implementation, return empty diagnostics
|
||||
return &host.GetDiagnosticsResponse{
|
||||
FileDiagnostics: []*cline.FileDiagnostics{},
|
||||
}, nil
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
package hostbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
proto "github.com/cline/grpc-go/host"
|
||||
)
|
||||
|
||||
// WindowService implements the proto.WindowServiceServer interface
|
||||
type WindowService struct {
|
||||
proto.UnimplementedWindowServiceServer
|
||||
verbose bool
|
||||
}
|
||||
|
||||
// NewWindowService creates a new WindowService
|
||||
func NewWindowService(verbose bool) *WindowService {
|
||||
return &WindowService{
|
||||
verbose: verbose,
|
||||
}
|
||||
}
|
||||
|
||||
// ShowTextDocument opens a text document for viewing/editing
|
||||
func (s *WindowService) ShowTextDocument(ctx context.Context, req *proto.ShowTextDocumentRequest) (*proto.TextEditorInfo, error) {
|
||||
if s.verbose {
|
||||
log.Printf("ShowTextDocument called for path: %s", req.GetPath())
|
||||
}
|
||||
|
||||
// For console implementation, we'll just log that we would open the document
|
||||
fmt.Printf("[Cline] Would open document: %s\n", req.GetPath())
|
||||
|
||||
return &proto.TextEditorInfo{
|
||||
DocumentPath: req.GetPath(),
|
||||
IsActive: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ShowOpenDialogue shows a file open dialog
|
||||
func (s *WindowService) ShowOpenDialogue(ctx context.Context, req *proto.ShowOpenDialogueRequest) (*proto.SelectedResources, error) {
|
||||
if s.verbose {
|
||||
log.Printf("ShowOpenDialogue called")
|
||||
}
|
||||
|
||||
// For console implementation, return empty list (user cancelled)
|
||||
return &proto.SelectedResources{
|
||||
Paths: []string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ShowMessage displays a message to the user
|
||||
func (s *WindowService) ShowMessage(ctx context.Context, req *proto.ShowMessageRequest) (*proto.SelectedResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("ShowMessage called: %s", req.GetMessage())
|
||||
}
|
||||
|
||||
// Display message to console
|
||||
fmt.Printf("[Cline] %s\n", req.GetMessage())
|
||||
|
||||
return &proto.SelectedResponse{}, nil
|
||||
}
|
||||
|
||||
// ShowInputBox shows an input dialog to the user
|
||||
func (s *WindowService) ShowInputBox(ctx context.Context, req *proto.ShowInputBoxRequest) (*proto.ShowInputBoxResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("ShowInputBox called: %s", req.GetTitle())
|
||||
}
|
||||
|
||||
// For console implementation, return empty response (user cancelled)
|
||||
return &proto.ShowInputBoxResponse{}, nil
|
||||
}
|
||||
|
||||
// ShowSaveDialog shows a save file dialog
|
||||
func (s *WindowService) ShowSaveDialog(ctx context.Context, req *proto.ShowSaveDialogRequest) (*proto.ShowSaveDialogResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("ShowSaveDialog called")
|
||||
}
|
||||
|
||||
// For console implementation, return empty response (user cancelled)
|
||||
return &proto.ShowSaveDialogResponse{}, nil
|
||||
}
|
||||
|
||||
// OpenFile opens a file in the editor
|
||||
func (s *WindowService) OpenFile(ctx context.Context, req *proto.OpenFileRequest) (*proto.OpenFileResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("OpenFile called for path: %s", req.GetFilePath())
|
||||
}
|
||||
|
||||
// For console implementation, just log that we would open the file
|
||||
fmt.Printf("[Cline] Would open file: %s\n", req.GetFilePath())
|
||||
|
||||
return &proto.OpenFileResponse{}, nil
|
||||
}
|
||||
|
||||
// GetOpenTabs returns a list of currently open tabs
|
||||
func (s *WindowService) GetOpenTabs(ctx context.Context, req *proto.GetOpenTabsRequest) (*proto.GetOpenTabsResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("GetOpenTabs called")
|
||||
}
|
||||
|
||||
// For console implementation, return empty list
|
||||
return &proto.GetOpenTabsResponse{
|
||||
Paths: []string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetVisibleTabs returns a list of currently visible tabs
|
||||
func (s *WindowService) GetVisibleTabs(ctx context.Context, req *proto.GetVisibleTabsRequest) (*proto.GetVisibleTabsResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("GetVisibleTabs called")
|
||||
}
|
||||
|
||||
// For console implementation, return empty list
|
||||
return &proto.GetVisibleTabsResponse{
|
||||
Paths: []string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetActiveEditor returns information about the current active editor
|
||||
func (s *WindowService) GetActiveEditor(ctx context.Context, req *proto.GetActiveEditorRequest) (*proto.GetActiveEditorResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("GetActiveEditor called")
|
||||
}
|
||||
|
||||
// Return empty response (no active file)
|
||||
return &proto.GetActiveEditorResponse{
|
||||
FilePath: nil,
|
||||
}, nil
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
# 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 by default
|
||||
- Anonymous telemetry and usage statistics are only collected if you explicitly opt in
|
||||
- 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
|
||||
- Optional anonymous telemetry and error reporting via PostHog if you opt in
|
||||
- You control what information to include when manually 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
|
||||
|
||||
## Telemetry & Usage Statistics
|
||||
|
||||
If you choose to opt in to anonymous telemetry:
|
||||
|
||||
- Basic usage statistics and error reports are collected via PostHog
|
||||
- A stable, anonymous identifier (VS Code's `machineId`) is used to understand unique usage patterns
|
||||
- This identifier is not linked to any personal information
|
||||
- It helps us understand how features are used across sessions
|
||||
- It cannot be used to identify you personally
|
||||
- All data is anonymized and cannot be linked to individual users
|
||||
- No code content or sensitive information is ever included
|
||||
- You can opt out at any time through:
|
||||
- VS Code Settings > Cline > Enable Telemetry
|
||||
- VS Code Settings > Telemetry > Telemetry Level (setting this to anything other than "all" will disable Cline's telemetry)
|
||||
- Collected data helps us improve the extension's functionality and stability
|
||||
|
||||
## 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
|
||||
@@ -0,0 +1,38 @@
|
||||
# Cline Documentation
|
||||
|
||||
Welcome to the Cline documentation - your comprehensive guide to using and extending Cline's capabilities. Here you'll find resources to help you get started, improve your skills, and contribute to the project.
|
||||
|
||||
## Getting Started
|
||||
|
||||
- **New to coding?** We've prepared a gentle introduction:
|
||||
- [Getting Started for New Coders](getting-started-new-coders/README.md)
|
||||
|
||||
## Improving Your Prompting Skills
|
||||
|
||||
- **Want to communicate more effectively with Cline?** Explore:
|
||||
- [Prompt Engineering Guide](prompting/README.md)
|
||||
- [Cline Memory Bank](prompting/custom%20instructions%20library/cline-memory-bank.md)
|
||||
|
||||
## Exploring Cline's Tools
|
||||
|
||||
- **Understand Cline's capabilities:**
|
||||
|
||||
- [Cline Tools Guide](tools/cline-tools-guide.md)
|
||||
|
||||
- **Extend Cline with MCP Servers:**
|
||||
- [MCP Overview](mcp/README.md)
|
||||
- [Building MCP Servers from GitHub](mcp/mcp-server-from-github.md)
|
||||
- [Building Custom MCP Servers](mcp/mcp-server-from-scratch.md)
|
||||
|
||||
## Contributing to Cline
|
||||
|
||||
- **Interested in contributing?** We welcome your input:
|
||||
- Feel free to submit a pull request
|
||||
- [Contribution Guidelines](../CONTRIBUTING.md)
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- **Cline GitHub Repository:** [https://github.com/cline/cline](https://github.com/cline/cline)
|
||||
- **MCP Documentation:** [https://modelcontextprotocol.org/docs](https://modelcontextprotocol.org/docs)
|
||||
|
||||
We're always looking to improve this documentation. If you have suggestions or find areas that could be enhanced, please let us know. Your feedback helps make Cline better for everyone.
|
||||
@@ -0,0 +1,43 @@
|
||||
# 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
|
||||
@@ -0,0 +1,41 @@
|
||||
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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user