Compare commits

..
Author SHA1 Message Date
Elephant Lumps fcf734076b one more 2025-06-03 00:22:54 -07:00
Elephant Lumps 02a50aeebc revert other test 'fixes' 2025-06-03 00:21:59 -07:00
Elephant Lumps fb49628a44 temporarily disable tests 2025-06-03 00:19:45 -07:00
Elephant Lumps b2a2aaf402 try again 2025-06-03 00:14:41 -07:00
Elephant Lumps e947218f0b try again please lord 2025-06-02 20:15:41 -07:00
Elephant Lumps ac489ff215 fix test mocks once and for all 2025-06-02 20:05:14 -07:00
Elephant Lumps 156f40d9ef fix test mocks once and for all 2025-06-02 20:04:08 -07:00
Elephant Lumps cea467d252 continue to fix tests 2025-06-02 19:41:54 -07:00
Elephant Lumps 8c3a8237f2 add test mock 2025-06-02 19:33:26 -07:00
Elephant Lumps 9a2672a0dc prettier 2025-06-02 18:33:32 -07:00
Elephant Lumps f1256ef53a merge conflicts 2025-06-02 18:33:15 -07:00
Elephant Lumps 462bca65f7 send targeted event to the controller 2025-06-02 18:27:21 -07:00
Elephant Lumps 6cdede57b7 merge conflicts 2025-06-02 14:42:00 -07:00
Elephant Lumps 1e767bcecf changeset 2025-05-27 11:34:33 -07:00
Elephant Lumps f26f6dc37c migrate chatButtonClicked 2025-05-27 11:34:09 -07:00
1269 changed files with 96219 additions and 154591 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
add models to vertex ai
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Remove hard-coded temperature from LM Studio API requests and add support for `reasoning_content` in LM Studio API responses.
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
Update `xaiModels` object and `xaiDefaultModelId` in `src/shared/api.ts`
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Fix for checkpoints
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
fixing token counting for xai provider
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
Migrate mcpButtonClicked to protobus
+7
View File
@@ -0,0 +1,7 @@
---
"claude-dev": patch
---
fix(bedrock): Use ignoreCache for profile-based AWS credential loading
Ensures that AWS Bedrock provider always fetches fresh credentials when using IAM profiles by setting `ignoreCache: true` for `fromNodeProviderChain`. This resolves issues where externally updated credentials (e.g., by AWS Identity Manager) were not detected by Cline, requiring an extension restart. Manual credential handling remains unchanged.
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
add suppport for parsing csv and xlsx
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
Migrate historyButtonClicked to protobus
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
Add dev only button to open task conversation history
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Fixed search tool overloading conversation with massive outputs by setting a maximum overall byte limit for search tool responses
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
add model to nebius ai studio
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
update chat box ui
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
bug fix for ollama
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
update the openrouter model list when refreshing
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Fix Title for windows in cline
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
scroll to task timeline
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
Add grep tool with new parsing format
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
optionsResponse protobus migration
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Display delay information when retrying API calls
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
Pass type of webview (tab or sidebar) to webview so it knows what type it is
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
Add edit tool definition
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
change proto type
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
Migrate chatButtonClicked to Protobus
+1 -1
View File
@@ -716,7 +716,7 @@ The Controller class manages MCP servers through the McpHub service:
class Controller {
mcpHub?: McpHub
constructor(context: vscode.ExtensionContext, webviewProvider: WebviewProvider) {
constructor(context: vscode.ExtensionContext, outputChannel: vscode.OutputChannel, webviewProvider: WebviewProvider) {
this.mcpHub = new McpHub(this)
}
-89
View File
@@ -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
Heres 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,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
-3
View File
@@ -219,9 +219,6 @@ EOF
## Basic PR Commands
```bash
# Get current PR number
gh pr view --json number -q .number
# List open PRs
gh pr list
@@ -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 isnt 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 cant execute commands or read their output, you lose access to one of its most powerful capabilities.
Good Example:
- When Cline cant 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.
+6
View File
@@ -0,0 +1,6 @@
[codespell]
# Ref: https://github.com/codespell-project/codespell#using-a-config-file
skip = .git*,*.svg,package-lock.json,*.css,.codespellrc,locales
check-hidden = true
ignore-regex = (\b(optIn|isTaller)\b|https://\S+)
# ignore-words-list =
-48
View File
@@ -1,48 +0,0 @@
# Cline Development Environment Variables
# Copy this file to .env and fill in your actual values
# Values should be obtained from 1Password shared vault for development
# ============================================================================
# DEVELOPMENT FLAGS
# Recomend not changing these unless you know what you're doing they are set by the launch.json normally
# ============================================================================
# IS_DEV=true
# CLINE_ENVIRONMENT=local
# ============================================================================
# POSTHOG TELEMETRY (Existing)
# ============================================================================
# Get these values from 1Password shared vault
TELEMETRY_SERVICE_API_KEY=your-posthog-telemetry-api-key
ERROR_SERVICE_API_KEY=your-posthog-error-tracking-api-key
# ============================================================================
# TELEMETRY PROVIDER CONTROL
# ============================================================================
# Control which telemetry providers are active
POSTHOG_TELEMETRY_ENABLED=true # Enable PostHog telemetry (default: true)
# Set to false to disable Telemetry completely
# ============================================================================
# OPTIONAL DEVELOPMENT SETTINGS
# ============================================================================
# Uncomment and modify as needed for development
# Multi-root workspace debugging
# MULTI_ROOT_TRACE=true
# gRPC recorder for testing
# GRPC_RECORDER_ENABLED=true
# GRPC_RECORDER_FILE_NAME=test-recording
# Test mode
# E2E_TEST=true
# IS_TEST=true
# ============================================================================
# USAGE INSTRUCTIONS
# ============================================================================
# 1. Copy this file: cp .env.example .env
# 2. Get PostHog keys from 1Password shared vault
# 3. Update the values in .env
# 4. The .env file is gitignored for security
+26
View File
@@ -0,0 +1,26 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 6,
"sourceType": "module"
},
"plugins": ["@typescript-eslint", "eslint-rules"],
"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",
"eslint-rules/no-grpc-client-object-literals": "error"
},
"ignorePatterns": ["out", "dist", "**/*.d.ts"]
}
+1 -4
View File
@@ -1,4 +1 @@
/docs/
/.github/ @saoudrizwan @garoth @sjf
/README.md @saoudrizwan @nickbaumann98
/src/core/storage/ @celestial-vault
* @saoudrizwan @ocasta181 @NightTrek @pashpashpash @dcbartlett @saito-sv @Garoth
+67 -66
View File
@@ -1,69 +1,70 @@
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: provider-model
attributes:
label: Provider/Model
description: What provider and model were you using when the issue occurred?
placeholder: "e.g., cline:anthropic/claude-3.7-sonnet, gemini:gemini-2.5-pro-exp-03-25"
validations:
required: true
- 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: textarea
id: system-info
attributes:
label: System Info
description: What system information is relevant to the issue?
placeholder: "e.g., CPU: Intel Core i7-11700K, GPU: NVIDIA GeForce RTX 3070, RAM: 32GB DDR4"
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.
+4 -1
View File
@@ -1,4 +1,4 @@
blank_issues_enabled: false
blank_issues_enabled: true
contact_links:
- name: ✨ Feature Request
url: https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop
@@ -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
+3 -47
View File
@@ -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
@@ -65,15 +29,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
+28
View File
@@ -0,0 +1,28 @@
# Codespell configuration is within .codespellrc
---
name: Codespell
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
jobs:
codespell:
if: false
name: Check for spelling errors
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Annotate locations with typos
uses: codespell-project/codespell-problem-matcher@v1
- name: Codespell
uses: codespell-project/actions-codespell@v2
with:
only_warn: 1
-108
View File
@@ -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/
-75
View File
@@ -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
+9 -24
View File
@@ -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,8 +30,6 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.tag }}
- name: Setup Node.js
uses: actions/setup-node@v4
@@ -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
-25
View File
@@ -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 }}
-32
View File
@@ -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
+105 -141
View File
@@ -1,9 +1,6 @@
name: Tests
on:
push:
branches:
- main
workflow_dispatch:
pull_request:
branches:
@@ -17,45 +14,7 @@ permissions:
pull-requests: write # Needed to add comments/annotations to PRs
jobs:
quality-checks:
runs-on: ubuntu-latest
name: Quality Checks
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
node-version: 22
- name: Cache root dependencies
uses: actions/cache@v4
id: root-cache
with:
path: node_modules
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
- name: Cache webview-ui dependencies
uses: actions/cache@v4
id: webview-cache
with:
path: webview-ui/node_modules
key: ${{ runner.os }}-npm-webview-${{ hashFiles('webview-ui/package-lock.json') }}
- name: Install root dependencies
if: steps.root-cache.outputs.cache-hit != 'true'
run: npm ci
- name: Install webview-ui dependencies
if: steps.webview-cache.outputs.cache-hit != 'true'
run: cd webview-ui && npm ci
- name: Run Quality Checks (Parallel)
run: npm run ci:check-all
test:
needs: quality-checks
strategy:
fail-fast: false
matrix:
@@ -74,6 +33,18 @@ jobs:
with:
node-version: 22
# Setup Python for coverage script
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: "3.10"
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
pip install requests
# Cache root dependencies - only reuse if package-lock.json exactly matches
- name: Cache root dependencies
uses: actions/cache@v4
id: root-cache
@@ -81,6 +52,7 @@ jobs:
path: node_modules
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
# Cache webview-ui dependencies - only reuse if package-lock.json exactly matches
- name: Cache webview-ui dependencies
uses: actions/cache@v4
id: webview-cache
@@ -101,40 +73,46 @@ jobs:
run: |
npm config set script-shell "C:\\Program Files\\Git\\bin\\bash.exe"
# Build the extension and tests (without redundant checks)
- name: Type Check
run: npm run check-types
- name: ESLint Check
run: npm run lint
- name: Prettier / Format Check
run: npm run format
# Build the extension before running tests
- name: Build Tests and Extension
id: build_step
run: npm run ci:build
run: npm run pretest
- name: Unit Tests with coverage - Linux
id: unit_tests_linux
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os == 'Linux' }}
# Unit Tests disabled due to module system conflicts between backend and webview-ui
# - name: Unit Tests
# run: npm run test:unit
# Run extension tests with coverage
- name: Extension Tests with Coverage
id: extension_coverage
continue-on-error: true
run: |
npx nyc --nycrc-path .nycrc.unit.json --reporter=lcov npm run test:unit
- name: Unit Tests - Non-Linux
id: unit_tests_non_linux
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os != 'Linux' }}
run: |
npm run test:unit
- name: Extension Integration Tests - Linux
id: integration_tests_linux
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os == 'Linux' }}
run: xvfb-run -a npm run test:coverage
- name: Extension Integration Tests - Non-Linux
id: integration_tests_non_linux
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os != 'Linux' }}
run: npm run test:integration
node ./scripts/test-ci.js > extension_coverage.txt 2>&1
# Default the encoding to UTF-8 - It's not the default on Windows
PYTHONUTF8=1 PYTHONPATH=.github/scripts python -m coverage_check extract-coverage extension_coverage.txt --type=extension --github-output --verbose
# Run webview tests with coverage
- name: Webview Tests with Coverage
id: webview_tests
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
id: webview_coverage
continue-on-error: true
run: |
cd webview-ui
npm run test:coverage
# Ensure coverage dependency is installed
npm install --no-save @vitest/coverage-v8
npm run test:coverage > webview_coverage.txt 2>&1
cd ..
# Default the encoding to UTF-8 - It's not the default on Windows
PYTHONUTF8=1 PYTHONPATH=.github/scripts python -m coverage_check extract-coverage webview-ui/webview_coverage.txt --type=webview --github-output --verbose
# Save coverage reports as artifacts (workflow-scoped)
- name: Save Coverage Reports
uses: actions/upload-artifact@v4
# Only upload artifacts on Linux - We only need coverage from one OS
@@ -142,21 +120,54 @@ jobs:
with:
name: pr-coverage-reports
path: |
coverage-unit/lcov.info
webview-ui/coverage/lcov.info
extension_coverage.txt
webview-ui/webview_coverage.txt
retention-period: workflow # Artifacts are automatically deleted when the workflow completes
test-platform-integration:
needs: quality-checks
# Set the check as failed if any of the tests failed
- name: Print test results and check for failures
run: |
echo "Extension Tests Result: ${{ steps.extension_coverage.outcome }}"
cat extension_coverage.txt
echo "Webview Tests Result: ${{ steps.webview_coverage.outcome }}"
cat webview-ui/webview_coverage.txt
# Check if any of the test steps failed
# https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/accessing-contextual-information-about-workflow-runs#steps-context
if [ "${{ steps.extension_coverage.outcome }}" != "success" ] || [ "${{ steps.webview_coverage.outcome }}" != "success" ]; then
echo "Tests failed."
exit 1
fi
coverage:
needs: test
runs-on: ubuntu-latest
# Only run on PRs to main branch
if: github.event_name == 'pull_request' && github.base_ref == 'main'
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0 # Fetch all history for accurate comparison
# Setup Python for coverage script
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: "3.10"
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
pip install requests
- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
node-version: 22
# Cache root dependencies - only reuse if package-lock.json exactly matches
- name: Cache root dependencies
uses: actions/cache@v4
id: root-cache
@@ -164,6 +175,7 @@ jobs:
path: node_modules
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
# Cache webview-ui dependencies - only reuse if package-lock.json exactly matches
- name: Cache webview-ui dependencies
uses: actions/cache@v4
id: webview-cache
@@ -171,14 +183,6 @@ jobs:
path: webview-ui/node_modules
key: ${{ runner.os }}-npm-webview-${{ hashFiles('webview-ui/package-lock.json') }}
# Cache testing-platform dependencies
- name: Cache testing-platform dependencies
uses: actions/cache@v4
id: testing-platform-cache
with:
path: testing-platform/node_modules
key: ${{ runner.os }}-npm-testing-platform-${{ hashFiles('testing-platform/package-lock.json') }}
- name: Install root dependencies
if: steps.root-cache.outputs.cache-hit != 'true'
run: npm ci
@@ -187,70 +191,30 @@ jobs:
if: steps.webview-cache.outputs.cache-hit != 'true'
run: cd webview-ui && npm ci
- name: Compile standalone
run: npm run compile-standalone
# Build the extension before running tests
- name: Build Extension
run: npm run compile
- name: Install testing platform dependencies
if: steps.testing-platform-cache.outputs.cache-hit != 'true'
run: cd testing-platform && npm ci
- name: Running testing platform integration spec tests
continue-on-error: true
timeout-minutes: 7
# Temporarily wrapping the test command to always return a neutral exit code.
# This prevents the job from showing as failed and avoids distracting developers
# until the integration tests are ready to be enforced.
run: |
npm run test:tp-orchestrator -- tests/specs/ --count=1 --coverage || true
- name: Save Coverage Reports
uses: actions/upload-artifact@v4
with:
name: test-platform-integration-core-coverage
path: coverage/**/lcov.info
qlty:
needs: [test, test-platform-integration]
runs-on: ubuntu-latest
# Run on PRs to main, pushes to main, and manual dispatches
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Download unit tests coverage reports
# Download coverage artifacts from test job
- name: Download Coverage Reports
uses: actions/download-artifact@v4
with:
name: pr-coverage-reports
path: .
path: . # Download to root directory to match expected paths
- name: Upload core unit tests coverage to Qlty
uses: qltysh/qlty-action/coverage@v2
with:
token: ${{ secrets.QLTY_COVERAGE_TOKEN }}
# we can merge multiple files if necessary
files: |
coverage-unit/lcov.info
tag: unit:core
# Process coverage workflow
- name: Process coverage workflow
id: coverage
run: |
# Extract PR number from GITHUB_REF
PR_NUMBER=$(echo "$GITHUB_REF" | sed -e 's/refs\/pull\///' -e 's/\/merge//')
- name: Upload webview-ui unit tests coverage to Qlty
uses: qltysh/qlty-action/coverage@v2
with:
token: ${{ secrets.QLTY_COVERAGE_TOKEN }}
# we can merge multiple files if necessary
files: |
webview-ui/coverage/lcov.info
tag: unit:webview-ui
add-prefix: webview-ui/
- name: Download test platform integration core coverage artifact
uses: actions/download-artifact@v4
with:
name: test-platform-integration-core-coverage
path: integration-core-coverage-reports
- name: Upload core integration tests coverage to Qlty
uses: qltysh/qlty-action/coverage@v2
with:
token: ${{ secrets.QLTY_COVERAGE_TOKEN }}
files: integration-core-coverage-reports/**/lcov.info
tag: integration:core
# Run the coverage workflow from root directory
PYTHONPATH=.github/scripts python -m coverage_check process-workflow \
--base-branch ${{ github.base_ref }} \
--pr-number $PR_NUMBER \
--repo $GITHUB_REPOSITORY \
--token ${{ secrets.GITHUB_TOKEN }} \
--verbose
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -1,53 +0,0 @@
name: Trigger Jetbrains Plugin <-> Cline Tests
on:
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
concurrency:
group: jetbrains-trigger-${{ github.event.number }}
cancel-in-progress: true
jobs:
trigger-integration-test:
name: Run Tests
runs-on: ubuntu-latest
steps:
- name: Generate GitHub App Token
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: 1998650
private-key: ${{ secrets.CLINE_JETBRAINS_WORKFLOW_KEY }}
owner: cline
repositories: intellij-plugin
- name: Trigger IntelliJ Plugin Integration Test
run: |
curl -X POST \
-H "Authorization: Bearer ${{ steps.app-token.outputs.token }}" \
-H "Accept: application/vnd.github.v3+json" \
-H "User-Agent: cline-pr-trigger" \
-H "Content-Type: application/json" \
https://api.github.com/repos/cline/intellij-plugin/dispatches \
-d @- <<EOF
{
"event_type": "cline-pr-check",
"client_payload": {
"pr_number": "${{ github.event.number }}",
"branch_name": "${{ github.head_ref }}",
"action": "${{ github.event.action }}",
"sha": "${{ github.event.pull_request.head.sha }}",
"pr_title": ${{ toJSON(github.event.pull_request.title) }},
"pr_url": "${{ github.event.pull_request.html_url }}"
}
}
EOF
- name: Log trigger details
run: |
echo "Triggered IntelliJ Plugin integration test for:"
echo " PR #${{ github.event.number }}: ${{ github.event.pull_request.title }}"
echo " Branch: ${{ github.head_ref }}"
echo " Action: ${{ github.event.action }}"
echo " SHA: ${{ github.event.pull_request.head.sha }}"
+6 -13
View File
@@ -7,7 +7,6 @@ tmp
*.vsix
.DS_Store
.idea
pnpm-lock.yaml
@@ -15,23 +14,17 @@ pnpm-lock.yaml
.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
.env
## Generated files ##
src/generated/
src/shared/proto/
# Generated proto files
src/shared/proto/*.ts
src/core/controller/*/methods.ts
src/core/controller/*/index.ts
src/core/controller/grpc-service-config.ts
webview-ui/src/services/grpc-client.ts
# E2E Tests
test-results
src/standalone/server-setup.ts
Regular → Executable
+17 -1
View File
@@ -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!"
+4 -13
View File
@@ -1,15 +1,6 @@
{
"extension": [
"ts"
],
"spec": [
"src/**/__tests__/*.ts"
],
"require": [
"ts-node/register",
"source-map-support/register",
"./src/test/requires.ts"
],
"recursive": true,
"exit": true
"extension": ["ts"],
"spec": "src/**/__tests__/*.ts",
"require": ["ts-node/register", "source-map-support/register", "./src/test/requires.ts"],
"recursive": true
}
-48
View File
@@ -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"
}
+7
View File
@@ -0,0 +1,7 @@
dist/
node_modules
webview-ui/build/
*.md
package-lock.json
src/core/prompts/system.ts
src/core/prompts/model_prompts/claude4.ts
+8
View File
@@ -0,0 +1,8 @@
{
"tabWidth": 4,
"useTabs": true,
"printWidth": 130,
"semi": false,
"bracketSameLine": true,
"endOfLine": "lf"
}
+1 -1
View File
@@ -2,7 +2,7 @@ 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
+2 -2
View File
@@ -2,9 +2,9 @@
// See http://go.microsoft.com/fwlink/?LinkId=827846
// for the documentation about the extensions.json format
"recommendations": [
"dbaeumer.vscode-eslint",
"connor4312.esbuild-problem-matchers",
"ms-vscode.extension-test-runner",
"bradlc.vscode-tailwindcss",
"biomejs.biome"
"bradlc.vscode-tailwindcss"
]
}
+19 -129
View File
@@ -6,66 +6,15 @@
"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}", "--disable-workspace-trust", "${workspaceFolder}"],
"outFiles": ["${workspaceFolder}/dist/**/*.js"],
"preLaunchTask": "${defaultBuildTask}",
"envFile": "${workspaceFolder}/.env",
"env": {
"IS_DEV": "true",
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}",
"CLINE_ENVIRONMENT": "production"
}
},
{
"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}",
"envFile": "${workspaceFolder}/.env",
"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}",
"envFile": "${workspaceFolder}/.env",
"env": {
"IS_DEV": "true",
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}",
"CLINE_ENVIRONMENT": "local"
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}"
}
},
{
@@ -74,97 +23,38 @@
"request": "launch",
"runtimeExecutable": "${execPath}",
"args": [
"--user-data-dir=${workspaceFolder}/dist/tmp/user",
"--profile-temp",
"--sync=off",
"--disable-extensions", // Avoid conflicts with installed extensions
"--sync",
"off",
"--disable-extensions",
"--extensionDevelopmentPath=${workspaceFolder}",
"${workspaceFolder}"
],
"outFiles": [
"${workspaceFolder}/dist/**/*.js"
],
"preLaunchTask": "clean-tmp-user",
"outFiles": ["${workspaceFolder}/dist/**/*.js"],
"preLaunchTask": "clean-sandbox",
"internalConsoleOptions": "openOnSessionStart",
"postDebugTask": "stop",
"envFile": "${workspaceFolder}/.env",
"env": {
"IS_DEV": "true",
"TEMP_PROFILE": "true",
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}",
"CLINE_ENVIRONMENT": "production"
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}"
}
},
{
"type": "node",
"request": "launch",
"name": "Debug Test Standalone Core Api Server (test:sca-server)",
"skipFiles": [
"<node_internals>/**"
],
"name": "Run Standalone Extension",
"skipFiles": ["<node_internals>/**"],
"sourceMaps": true,
"resolveSourceMapLocations": [
"${workspaceFolder}/**",
"!**/node_modules/**"
],
"cwd": "${workspaceFolder}",
"outFiles": [
"${workspaceFolder}/dist/**/*.js",
"${workspaceFolder}/dist-standalone/**/*.js"
],
"resolveSourceMapLocations": ["${workspaceFolder}/**", "!**/node_modules/**"],
"cwd": "${workspaceFolder}/dist-standalone",
"outFiles": ["${workspaceFolder}/dist-standalone/**/*.js"],
"preLaunchTask": "compile-standalone",
"runtimeExecutable": "npx",
"runtimeArgs": [
"tsx"
],
"program": "scripts/test-standalone-core-api-server.ts",
"envFile": "${workspaceFolder}/.env",
"env": {
"PROTOBUS_PORT": "26040",
"HOSTBRIDGE_PORT": "26041",
"WORKSPACE_DIR": "${workspaceFolder}",
"E2E_TEST": "true",
"CLINE_ENVIRONMENT": "local"
"GRPC_TRACE": "all",
"GRPC_VERBOSITY": "DEBUG",
"NODE_PATH": "${workspaceFolder}/dist-standalone/node_modules"
},
"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}"
],
"envFile": "${workspaceFolder}/.env",
"env": {
"TS_NODE_PROJECT": "./tsconfig.unit-test.json",
"NODE_ENV": "test",
"IS_DEV": "true",
"CLINE_ENVIRONMENT": "local"
},
"console": "integratedTerminal",
"internalConsoleOptions": "openOnSessionStart"
"program": "standalone.js"
}
]
}
+2 -20
View File
@@ -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"
}
+14 -76
View File
@@ -30,13 +30,7 @@
},
{
"label": "watch",
"dependsOn": [
"npm: protos",
"npm: build:webview",
"npm: dev:webview",
"npm: watch:tsc",
"npm: watch:esbuild"
],
"dependsOn": ["npm: protos", "npm: build:webview", "npm: dev:webview", "npm: watch:tsc", "npm: watch:esbuild"],
"presentation": {
"reveal": "always"
},
@@ -66,9 +60,7 @@
"problemMatcher": [],
"isBackground": true,
"label": "npm: build:webview",
"dependsOn": [
"npm: protos"
],
"dependsOn": ["npm: protos"],
"presentation": {
"group": "watch",
"reveal": "always"
@@ -86,9 +78,7 @@
"problemMatcher": [],
"isBackground": true,
"label": "npm: build:webview:test",
"dependsOn": [
"npm: protos"
],
"dependsOn": ["npm: protos"],
"presentation": {
"group": "watch",
"reveal": "always"
@@ -123,9 +113,7 @@
],
"isBackground": true,
"label": "npm: dev:webview",
"dependsOn": [
"npm: protos"
],
"dependsOn": ["npm: protos"],
"presentation": {
"group": "watch",
"reveal": "always"
@@ -140,30 +128,10 @@
"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"
],
"dependsOn": ["npm: protos"],
"presentation": {
"group": "watch",
"reveal": "always"
@@ -178,30 +146,10 @@
"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$"
}
},
"problemMatcher": "$esbuild-watch",
"isBackground": true,
"label": "npm: watch:esbuild:test",
"dependsOn": [
"npm: protos"
],
"dependsOn": ["npm: protos"],
"presentation": {
"group": "watch",
"reveal": "always"
@@ -220,9 +168,7 @@
"problemMatcher": "$tsc-watch",
"isBackground": true,
"label": "npm: watch:tsc",
"dependsOn": [
"npm: protos"
],
"dependsOn": ["npm: protos"],
"presentation": {
"group": "watch",
"reveal": "always"
@@ -233,9 +179,7 @@
"script": "watch-tests",
"problemMatcher": "$tsc-watch",
"isBackground": true,
"dependsOn": [
"npm: protos"
],
"dependsOn": ["npm: protos"],
"presentation": {
"reveal": "always",
"group": "watchers"
@@ -244,11 +188,7 @@
},
{
"label": "tasks: watch-tests",
"dependsOn": [
"npm: protos",
"npm: watch",
"npm: watch-tests"
],
"dependsOn": ["npm: protos", "npm: watch", "npm: watch-tests"],
"problemMatcher": []
},
{
@@ -257,12 +197,10 @@
"type": "shell"
},
{
"label": "clean-tmp-user",
"label": "clean-sandbox",
"type": "shell",
"dependsOn": [
"watch"
],
"command": "rm -rf ${workspaceFolder}/dist/tmp/user && mkdir -p ${workspaceFolder}/dist/tmp/user"
"dependsOn": ["watch"],
"command": "rm -rf .vscode-dev"
}
],
"inputs": [
+8 -32
View File
@@ -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,25 +32,17 @@ 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
!node_modules/@vscode/codicons/dist/codicon.ttf
# Include KaTeX CSS and fonts for LaTeX rendering
!webview-ui/node_modules/katex/dist/katex.min.css
!webview-ui/node_modules/katex/dist/fonts/**
# Include default themes JSON files used in getTheme
!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
+436 -985
View File
File diff suppressed because it is too large Load Diff
+14 -114
View File
@@ -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,26 +29,23 @@ 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`
- `libatk-bridge2.0-0`
- `libxkbfile1`
- `libx11-xcb1`
- `libxcomposite1`
- `libxdamage1`
- `libxfixes3`
- `libxkbfile1`
- `libxrandr2`
- `libgbm1`
- `libdrm2`
- `libgtk-3-0`
- `dbus`
- `xvfb`
These libraries provide necessary GUI components and system services for the test environment.
@@ -114,23 +54,13 @@ We also welcome contributions to our [documentation](https://github.com/cline/cl
```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
libatk1.0-0 libatk-bridge2.0-0 libxkbfile1 libx11-xcb1 \
libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 \
libdrm2 libgtk-3-0 dbus xvfb
```
- Run `npm run test:ci` to run tests locally
## 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 +76,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 +86,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`
+47 -3
View File
@@ -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, GCP Vertex, and Cerebras. 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
View File
@@ -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"
]
}
]
}
-21
View File
@@ -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.)
-2
View File
@@ -1,2 +0,0 @@
cline-core-debug.log
bin/*
-6
View File
@@ -1,6 +0,0 @@
/_____/\ /_/\ /_______/\/__/\ /__/\ /_____/\
\:::__\/ \:\ \ \__.::._\/\::\_\\ \ \\::::_\/_
\:\ \ __\:\ \ \::\ \ \:. `-\ \ \\:\/___/\
\:\ \/_/\\:\ \____ _\::\ \__\:. _ \ \\::___\/_
\:\_\ \ \\:\/___/\/__\::\__/\\. \`-\ \ \\:\____/\
\_____\/ \_____\/\________\/ \__\/ \__\/ \_____\/
-71
View File
@@ -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
}
-54
View File
@@ -1,54 +0,0 @@
package main
import (
"context"
"fmt"
"os"
"github.com/cline/cli/pkg/cli"
"github.com/cline/cli/pkg/cli/global"
"github.com/cline/cli/pkg/common"
"github.com/spf13/cobra"
)
var (
coreAddress string
verbose bool
outputFormat string
)
func main() {
rootCmd := &cobra.Command{
Use: "cline",
Short: "Cline CLI - AI-powered coding assistant",
Long: `A command-line interface for interacting with Cline AI coding assistant.
This CLI provides access to Cline's task management, configuration, and
monitoring capabilities from the terminal.`,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
if outputFormat != "rich" && outputFormat != "json" && outputFormat != "plain" {
return fmt.Errorf("invalid output format '%s': must be one of 'rich', 'json', or 'plain'", outputFormat)
}
return global.InitializeGlobalConfig(&global.GlobalConfig{
Verbose: verbose,
OutputFormat: outputFormat,
CoreAddress: coreAddress,
})
},
}
rootCmd.PersistentFlags().StringVar(&coreAddress, "address", fmt.Sprintf("localhost:%d", common.DEFAULT_CLINE_CORE_PORT), "Cline Core gRPC address")
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output")
rootCmd.PersistentFlags().StringVarP(&outputFormat, "output-format", "o", "rich", "output format (rich|json|plain)")
rootCmd.AddCommand(cli.NewTaskCommand())
rootCmd.AddCommand(cli.NewInstanceCommand())
rootCmd.AddCommand(cli.NewVersionCommand())
rootCmd.AddCommand(cli.NewAuthCommand())
rootCmd.AddCommand(cli.NewTaskSendCommand())
if err := rootCmd.ExecuteContext(context.Background()); err != nil {
os.Exit(1)
}
}
-154
View File
@@ -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)
}
}
-378
View File
@@ -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
}
-47
View File
@@ -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())
}
-120
View File
@@ -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)
}
}
-161
View File
@@ -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
}
-178
View File
@@ -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)
}
-58
View File
@@ -1,58 +0,0 @@
module github.com/cline/cli
go 1.23.0
require (
github.com/atotto/clipboard v0.1.4
github.com/charmbracelet/huh v0.7.0
github.com/cline/grpc-go v0.0.0
github.com/mattn/go-sqlite3 v1.14.24
github.com/spf13/cobra v1.8.0
google.golang.org/grpc v1.75.0
google.golang.org/protobuf v1.36.6
)
replace github.com/cline/grpc-go => ../src/generated/grpc-go
require (
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/catppuccin/go v0.3.0 // indirect
github.com/charmbracelet/bubbles v0.21.0 // indirect
github.com/charmbracelet/bubbletea v1.3.4 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/glamour v0.10.0 // indirect
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
github.com/charmbracelet/x/ansi v0.8.0 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/dlclark/regexp2 v1.11.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.7.8 // indirect
github.com/yuin/goldmark-emoji v1.0.5 // indirect
golang.org/x/net v0.41.0 // indirect
golang.org/x/sync v0.15.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/term v0.32.0 // indirect
golang.org/x/text v0.26.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect
)
-146
View File
@@ -1,146 +0,0 @@
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI=
github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY=
github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
github.com/charmbracelet/huh v0.7.0 h1:W8S1uyGETgj9Tuda3/JdVkc3x7DBLZYPZc4c+/rnRdc=
github.com/charmbracelet/huh v0.7.0/go.mod h1:UGC3DZHlgOKHvHC07a5vHag41zzhpPFj34U92sOmyuk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
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/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 h1:pFyd6EwwL2TqFf8emdthzeX+gZE1ElRq3iM8pui4KBY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4=
google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-17
View File
@@ -1,17 +0,0 @@
package cli
import (
"github.com/cline/cli/pkg/cli/auth"
"github.com/spf13/cobra"
)
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 auth.HandleAuthCommand(cmd.Context(), args)
},
}
}
-82
View File
@@ -1,82 +0,0 @@
package auth
import (
"context"
"fmt"
"github.com/charmbracelet/huh"
)
// AuthAction represents the type of authentication action
type AuthAction string
const (
AuthActionClineLogin AuthAction = "cline_login"
AuthActionBYOSetup AuthAction = "provider_setup"
)
// HandleAuthCommand routes the auth command based on the number of arguments
func HandleAuthCommand(ctx context.Context, args []string) error {
switch len(args) {
case 0:
// No args: Show menu (ShowAuthMenuNoArgs)
return HandleAuthMenuNoArgs(ctx)
case 1:
// One arg: Provider ID only, prompt for API key
return QuickAPISetup(args[0], "")
case 2:
// Two args: Provider ID and API key
return QuickAPISetup(args[0], args[1])
default:
return fmt.Errorf("too many arguments. Usage: cline auth [provider] [key]")
}
}
// HandleAuthMenuNoArgs offers Cline auth or provider setup when no args are given
func HandleAuthMenuNoArgs(ctx context.Context) error {
action, err := ShowAuthMenuNoArgs()
if err != nil {
return err
}
switch action {
case AuthActionClineLogin:
return HandleClineAuth(ctx)
case AuthActionBYOSetup:
return HandleAPIProviderSetup()
default:
return fmt.Errorf("invalid action")
}
}
// ShowAuthMenu displays the main auth menu and returns the selected action
func ShowAuthMenuNoArgs() (AuthAction, error) {
var action AuthAction
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[AuthAction]().
Title("What would you like to do?").
Options(
huh.NewOption("Authenticate with Cline account", AuthActionClineLogin),
huh.NewOption("Configure API provider", AuthActionBYOSetup),
).
Value(&action),
),
)
if err := form.Run(); err != nil {
return "", fmt.Errorf("failed to get menu choice: %w", err)
}
return action, nil
}
// HandleProviderSetup launches the API provider configuration wizard
func HandleAPIProviderSetup() error {
wizard, err := NewProviderWizard()
if err != nil {
return fmt.Errorf("failed to create provider wizard: %w", err)
}
return wizard.Run()
}
-13
View File
@@ -1,13 +0,0 @@
package auth
import "fmt"
// QuickAPISetup performs quick provider setup with provider ID and optional API key
func QuickAPISetup(providerID, apiKey string) error {
fmt.Println("Quick BYO API setup is currently stubbed - not yet implemented.")
fmt.Printf("Requested provider: %s\n", providerID)
if apiKey != "" {
fmt.Println("Provided API key:", "<jk redacted>")
}
return nil
}
-76
View File
@@ -1,76 +0,0 @@
package auth
import (
"fmt"
"github.com/charmbracelet/huh"
)
// ProviderWizard handles the interactive provider configuration process
type ProviderWizard struct{}
// NewProviderWizard creates a new provider configuration wizard
func NewProviderWizard() (*ProviderWizard, error) {
return &ProviderWizard{}, nil
}
// Run runs the provider configuration wizard
func (pw *ProviderWizard) Run() error {
fmt.Println("Welcome to Cline API Provider Configuration!")
fmt.Println("(Currently stubbed - full implementation coming soon)")
fmt.Println()
for {
action, err := pw.showMainMenu()
if err != nil {
return err
}
switch action {
case "add":
fmt.Println("Provider setup is currently stubbed - not yet implemented.")
case "remove":
fmt.Println("Provider removal is currently stubbed - not yet implemented.")
case "list":
fmt.Println("Provider listing is currently stubbed - not yet implemented.")
case "test":
fmt.Println("Provider testing is currently stubbed - not yet implemented.")
case "default":
fmt.Println("Setting default provider is currently stubbed - not yet implemented.")
case "save":
fmt.Println("No configuration to save.")
return nil
case "exit":
fmt.Println("Exiting configuration wizard.")
return nil
}
fmt.Println()
}
}
// showMainMenu displays the main provider configuration menu
func (pw *ProviderWizard) showMainMenu() (string, error) {
var action string
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("What would you like to do?").
Options(
huh.NewOption("Add a new provider", "add"),
huh.NewOption("Remove a provider", "remove"),
huh.NewOption("List configured providers", "list"),
huh.NewOption("Test provider connections", "test"),
huh.NewOption("Set default provider", "default"),
huh.NewOption("Save configuration and exit", "save"),
huh.NewOption("Exit without saving", "exit"),
).
Value(&action),
),
)
if err := form.Run(); err != nil {
return "", fmt.Errorf("failed to get menu choice: %w", err)
}
return action, nil
}
-120
View File
@@ -1,120 +0,0 @@
package auth
import (
"context"
"fmt"
"time"
"github.com/charmbracelet/huh"
"github.com/cline/cli/pkg/cli/global"
"github.com/cline/grpc-go/cline"
)
var isSessionAuthenticated bool
func HandleClineAuth(ctx context.Context) error {
fmt.Println("Authenticating with Cline...")
// Check if already authenticated
if IsAuthenticated(ctx) {
return signOutDialog(ctx)
}
// Perform sign in
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 {
var confirm bool
form := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title("You are already signed in to Cline.").
Description("Would you like to sign out?").
Value(&confirm),
),
)
if err := form.Run(); err != nil {
return nil
}
if confirm {
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 := global.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...)
}
}
-83
View File
@@ -1,83 +0,0 @@
package display
import (
"fmt"
"os"
"golang.org/x/term"
)
func isTTY() bool {
return term.IsTerminal(int(os.Stdout.Fd()))
}
func ClearLine() {
if !isTTY() {
return
}
fmt.Print("\r\033[K")
}
func MoveUp(n int) {
if !isTTY() || n <= 0 {
return
}
fmt.Printf("\033[%dA", n)
}
func ClearLines(n int) {
if !isTTY() || n <= 0 {
return
}
for i := 0; i < n; i++ {
MoveUp(1)
ClearLine()
}
}
// ClearToEnd clears from cursor to end of screen
func ClearToEnd() {
if !isTTY() {
return
}
fmt.Print("\033[J")
}
// ClearCurrentAndBelow clears N lines starting from current position
func ClearCurrentAndBelow(n int) {
if !isTTY() || n <= 0 {
return
}
// Clear n lines:
// - Clear current line
// - Move down and clear (n-1) more lines
// - Move back up to start
for i := 0; i < n; i++ {
fmt.Print("\033[K") // Clear from cursor to end of line
if i < n-1 {
fmt.Print("\033[1B\r") // Move down 1 line and to start
}
}
// Move back up to the first line we cleared
if n > 1 {
fmt.Printf("\033[%dA", n-1)
}
}
// SaveCursor saves the current cursor position
func SaveCursor() {
if !isTTY() {
return
}
fmt.Print("\033[s")
}
// RestoreCursor restores the cursor to the saved position
func RestoreCursor() {
if !isTTY() {
return
}
fmt.Print("\033[u")
}
-95
View File
@@ -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()
}
}
-104
View File
@@ -1,104 +0,0 @@
package display
import (
"os"
"strings"
"github.com/charmbracelet/glamour"
"golang.org/x/term"
)
type MarkdownRenderer struct {
renderer *glamour.TermRenderer
width int
}
func NewMarkdownRenderer() (*MarkdownRenderer, error) {
width := getTerminalWidth()
r, err := glamour.NewTermRenderer(
glamour.WithStandardStyle("tokyo-night"),
glamour.WithWordWrap(width),
glamour.WithPreservedNewLines(),
)
if err != nil {
return nil, err
}
return &MarkdownRenderer{
renderer: r,
width: width,
}, nil
}
func (mr *MarkdownRenderer) Render(markdown string) (string, error) {
rendered, err := mr.renderer.Render(markdown)
if err != nil {
return "", err
}
return strings.TrimRight(rendered, "\n"), nil
}
func (mr *MarkdownRenderer) CountLines(text string) int {
if text == "" {
return 0
}
// Split by newlines to get logical lines
lines := strings.Split(text, "\n")
visualLines := 0
// Count visual lines accounting for terminal width wrapping
for _, line := range lines {
// Strip ANSI codes to get actual visual width
visualWidth := stripAnsiLen(line)
if visualWidth == 0 {
// Empty line still takes up one visual line
visualLines++
} else {
// Calculate how many visual lines this logical line will take
// when wrapped at terminal width
visualLines += (visualWidth + mr.width - 1) / mr.width
}
}
return visualLines
}
// stripAnsiLen returns the visual length of a string after stripping ANSI escape codes
func stripAnsiLen(s string) int {
length := 0
inEscape := false
for i := 0; i < len(s); i++ {
if s[i] == '\033' && i+1 < len(s) && s[i+1] == '[' {
inEscape = true
i++ // Skip the '['
continue
}
if inEscape {
// Skip until we find the end of escape sequence (a letter)
if (s[i] >= 'A' && s[i] <= 'Z') || (s[i] >= 'a' && s[i] <= 'z') {
inEscape = false
}
continue
}
length++
}
return length
}
func getTerminalWidth() int {
width, _, err := term.GetSize(int(os.Stdout.Fd()))
if err != nil || width == 0 {
return 120
}
if width > 150 {
return 150
}
return width
}
-217
View File
@@ -1,217 +0,0 @@
package display
import (
"fmt"
"strings"
"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
mdRenderer *MarkdownRenderer
outputFormat string
}
func NewRenderer(outputFormat string) *Renderer {
mdRenderer, err := NewMarkdownRenderer()
if err != nil {
mdRenderer = nil
}
return &Renderer{
typewriter: NewTypewriterPrinter(DefaultTypewriterConfig()),
mdRenderer: mdRenderer,
outputFormat: outputFormat,
}
}
func (r *Renderer) RenderMessage(prefix, text string, newline bool) error {
if text == "" {
return nil
}
clean := r.sanitizeText(text)
if clean == "" {
return nil
}
if newline {
fmt.Printf("%s: %s\n", prefix, clean)
} else {
fmt.Printf("%s: %s", prefix, clean)
}
return nil
}
func (r *Renderer) RenderCheckpointMessage(timestamp, prefix string, id int64) error {
markdown := fmt.Sprintf("## [%s] Checkpoint created `%d`", timestamp, id)
rendered := r.RenderMarkdown(markdown)
fmt.Printf(rendered)
return nil
}
func (r *Renderer) RenderCommand(command string, isExecuting bool) error {
if isExecuting {
r.typewriter.PrintMessageLine("EXEC", command)
} else {
r.typewriter.PrintMessageLine("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)
}
func (r *Renderer) RenderAPI(status string, apiInfo *types.APIRequestInfo) error {
if apiInfo.Cost >= 0 {
usageInfo := r.formatUsageInfo(apiInfo.TokensIn, apiInfo.TokensOut, apiInfo.CacheReads, apiInfo.CacheWrites, apiInfo.Cost)
markdown := fmt.Sprintf("## API %s `%s`", status, usageInfo)
rendered := r.RenderMarkdown(markdown)
fmt.Printf("\n%s\n", rendered)
} else {
// honestly i see no point in showing "### API processing request" here...
// markdown := fmt.Sprintf("## API %s", status)
// rendered := r.RenderMarkdown(markdown)
// fmt.Printf("\n%s\n", rendered)
}
return nil
}
func (r *Renderer) RenderRetry(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("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 {
message := fmt.Sprintf(format, args...)
r.typewriter.PrintMessageLine("[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
}
// RenderMarkdown renders markdown text to terminal format with ANSI codes
// Falls back to plaintext if markdown rendering is unavailable or fails
// Respects output format - skips rendering in plain mode
func (r *Renderer) RenderMarkdown(markdown string) string {
// Skip markdown rendering in plain mode
if r.outputFormat == "plain" {
return markdown
}
if r.mdRenderer == nil {
return markdown
}
rendered, err := r.mdRenderer.Render(markdown)
if err != nil {
return markdown
}
return rendered
}
-311
View File
@@ -1,311 +0,0 @@
package display
import (
"encoding/json"
"fmt"
"strings"
"sync"
"time"
"github.com/cline/cli/pkg/cli/types"
)
type StreamingSegment struct {
mu sync.Mutex
sayType string
prefix string
buffer strings.Builder
lastRendered string
lastBuffer string
lastAppended string
lastLineCount int
timer *time.Timer
frozen bool
mdRenderer *MarkdownRenderer
shouldMarkdown bool
outputFormat string
msg *types.ClineMessage
}
func NewStreamingSegment(sayType, prefix string, mdRenderer *MarkdownRenderer, shouldMarkdown bool, msg *types.ClineMessage, outputFormat string) *StreamingSegment {
ss := &StreamingSegment{
sayType: sayType,
prefix: prefix,
mdRenderer: mdRenderer,
shouldMarkdown: shouldMarkdown,
outputFormat: outputFormat,
msg: msg,
}
// Render rich header immediately when creating segment (if in rich mode)
if shouldMarkdown && outputFormat != "plain" {
header := ss.generateRichHeader()
rendered, _ := mdRenderer.Render(header)
fmt.Println()
fmt.Print(rendered)
}
return ss
}
func (ss *StreamingSegment) AppendText(text string) {
ss.mu.Lock()
defer ss.mu.Unlock()
if ss.frozen {
return
}
// Replace buffer with FULL text - msg.Text contains complete accumulated content
ss.buffer.Reset()
ss.buffer.WriteString(text)
if ss.timer != nil {
ss.timer.Stop()
}
ss.timer = time.AfterFunc(150*time.Millisecond, func() {
ss.Render()
})
}
func (ss *StreamingSegment) Render() error {
ss.mu.Lock()
defer ss.mu.Unlock()
if ss.frozen {
return nil
}
currentBuffer := ss.buffer.String()
if currentBuffer == ss.lastBuffer {
return nil
}
// Prepare text for rendering
text, shouldSkip := ss.prepareTextForRender(currentBuffer)
if shouldSkip {
return nil
}
// Render text to markdown or plain text
rendered := ss.renderMarkdown(text, currentBuffer)
// Calculate new line count
newLineCount := ss.mdRenderer.CountLines(rendered)
if !strings.HasSuffix(rendered, "\n") {
newLineCount++
}
// LIVE markdown rendering
// Clear previous render (if any)
if ss.lastLineCount > 0 {
ClearLines(ss.lastLineCount)
} else {
// First render - add blank line before segment
fmt.Println()
}
// Print live markdown
fmt.Print(rendered)
// Track how many lines we actually printed (not including any trailing newline we might add)
actualLines := strings.Count(rendered, "\n")
// Add final newline if needed
if !strings.HasSuffix(rendered, "\n") {
fmt.Println()
actualLines++ // Count the newline we just added
}
// Save this for next clear
ss.lastLineCount = actualLines
// Update state
ss.lastRendered = rendered
ss.lastBuffer = currentBuffer
return nil
}
func (ss *StreamingSegment) Freeze() {
ss.mu.Lock()
if ss.frozen {
ss.mu.Unlock()
return
}
if ss.timer != nil {
ss.timer.Stop()
ss.timer = nil
}
ss.frozen = true
currentBuffer := ss.buffer.String()
needsRender := currentBuffer != ss.lastBuffer
ss.mu.Unlock()
if needsRender {
ss.renderFinal(currentBuffer)
}
}
func (ss *StreamingSegment) renderFinal(currentBuffer string) {
ss.mu.Lock()
defer ss.mu.Unlock()
// Prepare text for rendering
text, shouldSkip := ss.prepareTextForRender(currentBuffer)
if shouldSkip {
return
}
// Render text to markdown or plain text
rendered := ss.renderMarkdown(text, currentBuffer)
if ss.lastLineCount > 0 {
ss.clearPrevious()
}
// Print final render (frozen segments stay permanent)
if !strings.HasSuffix(rendered, "\n") {
fmt.Print(rendered)
fmt.Println()
} else {
fmt.Print(rendered)
}
ss.lastRendered = rendered
ss.lastBuffer = currentBuffer
// No need to track line count after freeze - segment is permanent
ss.lastLineCount = 0
}
func (ss *StreamingSegment) clearPrevious() {
ClearLines(ss.lastLineCount)
}
// prepareTextForRender transforms raw buffer text into display-ready text.
// Returns the prepared text and a boolean indicating whether rendering should be skipped.
func (ss *StreamingSegment) prepareTextForRender(currentBuffer string) (string, bool) {
text := currentBuffer
// For ASK messages, parse JSON and extract response field
if ss.sayType == "ask" {
var askData types.AskData
if err := json.Unmarshal([]byte(currentBuffer), &askData); err == nil {
// Use the response field as the text to render
text = askData.Response
// Add options if available
if len(askData.Options) > 0 {
text += "\n\nOptions:\n"
for i, option := range askData.Options {
text += fmt.Sprintf("%d. %s\n", i+1, option)
}
}
}
}
// For tools, parse JSON and decide what to show
if ss.sayType == string(types.SayTypeTool) {
var tool types.ToolMessage
if err := json.Unmarshal([]byte(currentBuffer), &tool); err == nil {
// Tools that show no body - header is sufficient
switch tool.Tool {
case "readFile", "listFilesTopLevel", "listFilesRecursive",
"listCodeDefinitionNames", "searchFiles", "webFetch":
return "", true // Skip body rendering, header already shown
case "editedExistingFile":
// Show the diff (stored in Content field)
if tool.Content != "" {
text = "```diff\n" + tool.Content + "\n```"
} else {
return "", true // No diff yet, just show header
}
default:
// Other tools: suppress JSON body for now
return "", true
}
}
}
// Wrap command text in shell code block
if ss.sayType == string(types.SayTypeCommand) {
text = "```shell\n" + text + "\n```"
}
return text, false
}
// renderMarkdown converts prepared text to rendered markdown or plain text.
// Falls back to plain text with prefix if markdown rendering fails.
func (ss *StreamingSegment) renderMarkdown(text string, fallbackBuffer string) string {
if ss.shouldMarkdown && ss.outputFormat != "plain" {
rendered, err := ss.mdRenderer.Render(text)
if err != nil {
return ss.prefix + ": " + fallbackBuffer
}
return rendered
}
return ss.prefix + ": " + fallbackBuffer
}
// generateRichHeader generates a contextual header for the segment
func (ss *StreamingSegment) generateRichHeader() string {
switch ss.sayType {
case string(types.SayTypeReasoning):
return "### Cline is thinking\n"
case string(types.SayTypeText):
return "### Cline responds\n"
case string(types.SayTypeCompletionResult):
return "### Task completed\n"
case string(types.SayTypeTool):
return ss.generateToolHeader()
case "ask":
return "### Cline has a plan\n"
default:
return fmt.Sprintf("### %s\n", ss.prefix)
}
}
// generateToolHeader generates a contextual header for tool operations
func (ss *StreamingSegment) generateToolHeader() string {
// Parse tool JSON from message text
var tool types.ToolMessage
if err := json.Unmarshal([]byte(ss.msg.Text), &tool); err != nil {
return "### Tool operation\n"
}
switch tool.Tool {
case "readFile":
if tool.Path != "" {
return fmt.Sprintf("### Cline is reading `%s`\n", tool.Path)
}
return "### Cline is reading a file\n"
case "writeFile", "newFileCreated":
if tool.Path != "" {
return fmt.Sprintf("### Cline is writing `%s`\n", tool.Path)
}
return "### Cline is writing a file\n"
case "editedExistingFile":
if tool.Path != "" {
return fmt.Sprintf("### Cline is editing `%s`\n", tool.Path)
}
return "### Cline is editing a file\n"
default:
return fmt.Sprintf("### Tool: %s\n", tool.Tool)
}
}
-588
View File
@@ -1,588 +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
activeSegment *StreamingSegment
mdRenderer *MarkdownRenderer
}
// NewStreamingDisplay creates a new streaming display manager
func NewStreamingDisplay(state *types.ConversationState, renderer *Renderer) *StreamingDisplay {
mdRenderer, err := NewMarkdownRenderer()
if err != nil {
mdRenderer = nil
}
return &StreamingDisplay{
state: state,
renderer: renderer,
dedupe: NewMessageDeduplicator(),
mdRenderer: mdRenderer,
}
}
// HandlePartialMessage processes partial messages with streaming support
func (s *StreamingDisplay) HandlePartialMessage(msg *types.ClineMessage) error {
s.mu.Lock()
defer s.mu.Unlock()
// Check for deduplication
if s.dedupe.IsDuplicate(msg) {
return nil
}
// Skip if markdown renderer not available, fall back to old behavior
if s.mdRenderer == nil {
messageKey := fmt.Sprintf("%d", msg.Timestamp)
timestamp := msg.GetTimestamp()
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("CLINE", msg.Text, true)
}
}
// Segment-based markdown streaming
sayType := msg.Say
if msg.Type == types.MessageTypeAsk {
sayType = "ask"
}
// Detect segment boundary
if s.activeSegment != nil && s.activeSegment.sayType != sayType {
s.activeSegment.Freeze()
s.activeSegment = nil
}
// Start new segment if needed
if s.activeSegment == nil {
shouldMd := s.shouldRenderMarkdown(sayType)
prefix := s.getPrefix(sayType)
s.activeSegment = NewStreamingSegment(sayType, prefix, s.mdRenderer, shouldMd, msg, s.renderer.outputFormat)
}
// Append text to active segment
if msg.Text != "" {
s.activeSegment.AppendText(msg.Text)
}
// If message is complete, freeze segment
if !msg.Partial {
s.activeSegment.Freeze()
s.activeSegment = nil
}
return nil
}
// 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 {
s.finishCurrentStream()
fmt.Println()
s.streamAskMessage(cleanText, timestamp, true)
s.state.SetStreamingMessage(messageKey, cleanText)
}
return nil
}
// handleStreamingSay handles streaming SAY messages
func (s *StreamingDisplay) handleStreamingSay(msg *types.ClineMessage, messageKey, timestamp string, streamingMsg *types.StreamingMessage) error {
switch msg.Say {
case string(types.SayTypeText), string(types.SayTypeCompletionResult), string(types.SayTypeReasoning):
return s.handleStreamingText(msg, messageKey, timestamp, streamingMsg)
case string(types.SayTypeCommand):
return s.handleStreamingCommand(msg, messageKey, timestamp, streamingMsg)
case string(types.SayTypeCommandOutput):
return s.handleStreamingCommandOutput(msg, messageKey, timestamp, streamingMsg)
case string(types.SayTypeShellIntegrationWarning):
return s.handleShellIntegrationWarning(msg, messageKey, timestamp, streamingMsg)
default:
// For non-streaming message types, use regular display
return s.renderer.RenderMessage(s.getMessagePrefix(msg.Say), msg.Text, true)
}
}
// 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 {
s.renderer.ClearLine()
prefix := s.getMessagePrefix(msg.Say)
if msg.Say == string(types.SayTypeReasoning) || msg.Say == string(types.SayTypeText) || msg.Say == string(types.SayTypeCompletionResult) {
s.renderer.typewriter.PrintfInstant("%s: ", prefix)
} else {
s.renderer.typewriter.PrintfInstant("[%s] %s: ", timestamp, prefix)
}
s.typewriterPrint(cleanText)
s.state.SetStreamingMessage(messageKey, cleanText)
}
} else {
s.finishCurrentStream()
fmt.Println()
prefix := s.getMessagePrefix(msg.Say)
if msg.Say == string(types.SayTypeReasoning) || msg.Say == string(types.SayTypeText) || msg.Say == string(types.SayTypeCompletionResult) {
s.renderer.typewriter.PrintfInstant("%s: ", prefix)
} else {
s.renderer.typewriter.PrintfInstant("[%s] %s: ", timestamp, prefix)
}
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
}
s.finishCurrentStream()
fmt.Println()
s.renderer.typewriter.PrintfInstant("CMD: ")
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 {
s.renderer.ClearLine()
s.renderer.typewriter.PrintfInstant("OUT: ")
s.typewriterPrint(cleanText)
s.state.SetStreamingMessage(messageKey, cleanText)
}
} else {
s.finishCurrentStream()
fmt.Println()
s.renderer.typewriter.PrintfInstant("OUT: ")
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
}
s.finishCurrentStream()
fmt.Println()
s.renderer.typewriter.PrintfInstant("NOTE: ")
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
}
// Parse the tool JSON to extract structured information
var toolData types.ToolMessage
if err := json.Unmarshal([]byte(cleanText), &toolData); err != nil {
// If parsing fails, just show generic tool message
s.finishCurrentStream()
fmt.Println()
fmt.Printf("TOOL: %s\n", cleanText)
s.state.StreamingMessage.LastToolMessage = cleanText
return nil
}
// Format the tool message nicely
formattedTool := s.formatStructuredToolMessage(&toolData)
// 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
}
s.finishCurrentStream()
fmt.Println()
fmt.Printf("TOOL: %s\n", 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 {
fmt.Printf("ASK: %s", text)
return
}
fmt.Printf("ASK: %s", 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("ASK: %s", 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("ASK: %s", 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()
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 "CLINE"
case string(types.SayTypeReasoning):
return "THINKING"
default:
return "CLINE"
}
}
// formatToolMessage formats tool call messages for better readability (legacy, keep for compatibility)
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
}
// formatStructuredToolMessage formats a parsed ToolMessage for display
func (s *StreamingDisplay) formatStructuredToolMessage(tool *types.ToolMessage) string {
parts := []string{tool.Tool}
if tool.Path != "" {
parts = append(parts, fmt.Sprintf("path=%s", tool.Path))
}
if tool.Content != "" {
if len(tool.Content) > 50 {
parts = append(parts, fmt.Sprintf("content=%s...", tool.Content[:50]))
} else {
parts = append(parts, fmt.Sprintf("content=%s", tool.Content))
}
}
if tool.Regex != "" {
parts = append(parts, fmt.Sprintf("regex=%s", tool.Regex))
}
return strings.Join(parts, " ")
}
// 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)
}
func (s *StreamingDisplay) getMessageType(msg *types.ClineMessage) string {
if msg.Type == types.MessageTypeAsk {
return "ASK"
}
switch msg.Say {
case string(types.SayTypeText):
return "CLINE"
case string(types.SayTypeReasoning):
return "THINKING"
case string(types.SayTypeCompletionResult):
return "RESULT"
case string(types.SayTypeCommand):
return "CMD"
default:
return msg.Say
}
}
func (s *StreamingDisplay) shouldRenderMarkdown(sayType string) bool {
switch sayType {
case string(types.SayTypeReasoning), string(types.SayTypeText), string(types.SayTypeCompletionResult), string(types.SayTypeTool), "ask":
return true
default:
return false
}
}
func (s *StreamingDisplay) getPrefix(sayType string) string {
switch sayType {
case string(types.SayTypeReasoning):
return "THINKING"
case string(types.SayTypeText):
return "CLINE"
case string(types.SayTypeCompletionResult):
return "RESULT"
case "ask":
return "ASK"
case string(types.SayTypeCommand):
return "TERMINAL"
default:
return strings.ToUpper(sayType)
}
}
func (s *StreamingDisplay) FreezeActiveSegment() {
s.mu.Lock()
defer s.mu.Unlock()
if s.activeSegment != nil {
s.activeSegment.Freeze()
s.activeSegment = nil
}
}
// Cleanup cleans up streaming display resources
func (s *StreamingDisplay) Cleanup() {
s.FreezeActiveSegment()
if s.dedupe != nil {
s.dedupe.Stop()
}
}
-210
View File
@@ -1,210 +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)
}
func (tp *TypewriterPrinter) PrintMessageLine(prefix, text string) {
tp.PrintfInstant("%s: ", prefix)
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...)
}
func TypewriterPrintMessageLine(prefix, text string) {
globalTypewriter.PrintMessageLine(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
}
-274
View File
@@ -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
}
-91
View File
@@ -1,91 +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)
}
// EnsureDefaultInstance ensures a default instance exists
func EnsureDefaultInstance(ctx context.Context) error {
if Clients == nil {
return fmt.Errorf("global clients not initialized")
}
// Check if we have any instances in the registry
registry := Clients.GetRegistry()
if registry.GetDefaultInstance() == "" {
// No default instance, start a new one
instance, err := 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
}
-267
View File
@@ -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
}
-351
View File
@@ -1,351 +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()
}
func (h *AskHandler) Handle(msg *types.ClineMessage, dc *DisplayContext) error {
switch msg.Ask {
case string(types.AskTypeFollowup):
return h.handleFollowup(msg, dc)
case string(types.AskTypePlanModeRespond):
return h.handlePlanModeRespond(msg, dc)
case string(types.AskTypeCommand):
return h.handleCommand(msg, dc)
case string(types.AskTypeCommandOutput):
return h.handleCommandOutput(msg, dc)
case string(types.AskTypeCompletionResult):
return h.handleCompletionResult(msg, dc)
case string(types.AskTypeTool):
return h.handleTool(msg, dc)
case string(types.AskTypeAPIReqFailed):
return h.handleAPIReqFailed(msg, dc)
case string(types.AskTypeResumeTask):
return h.handleResumeTask(msg, dc)
case string(types.AskTypeResumeCompletedTask):
return h.handleResumeCompletedTask(msg, dc)
case string(types.AskTypeMistakeLimitReached):
return h.handleMistakeLimitReached(msg, dc)
case string(types.AskTypeAutoApprovalMaxReached):
return h.handleAutoApprovalMaxReached(msg, dc)
case string(types.AskTypeBrowserActionLaunch):
return h.handleBrowserActionLaunch(msg, dc)
case string(types.AskTypeUseMcpServer):
return h.handleUseMcpServer(msg, dc)
case string(types.AskTypeNewTask):
return h.handleNewTask(msg, dc)
case string(types.AskTypeCondense):
return h.handleCondense(msg, dc)
case string(types.AskTypeReportBug):
return h.handleReportBug(msg, dc)
default:
return h.handleDefault(msg, dc)
}
}
// handleFollowup handles followup questions
func (h *AskHandler) handleFollowup(msg *types.ClineMessage, dc *DisplayContext) 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("QUESTION", question, true)
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) 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
}
markdown := fmt.Sprintf("### Cline has a plan\n\n%s", response)
rendered := dc.Renderer.RenderMarkdown(markdown)
fmt.Printf("\n%s\n", rendered)
// 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) 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("TERMINAL", "Cline wants to execute this command:", true)
if err != nil {
return fmt.Errorf("failed to render handleCommand: %w", err)
}
// Render markdown with syntax highlighting
markdown := fmt.Sprintf("```shell\n%s\n```", strings.TrimSpace(command))
rendered := dc.Renderer.RenderMarkdown(markdown)
fmt.Printf("\n%s\n", rendered)
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) error {
if msg.Text == "" {
return nil
}
commandOutput := msg.Text
markdown := fmt.Sprintf("```\n%s\n```", commandOutput)
rendered := dc.Renderer.RenderMarkdown(markdown)
fmt.Printf("%s", rendered)
return nil
}
// handleCompletionResult handles completion result requests
func (h *AskHandler) handleCompletionResult(msg *types.ClineMessage, dc *DisplayContext) error {
return nil
}
// handleTool handles tool execution requests
func (h *AskHandler) handleTool(msg *types.ClineMessage, dc *DisplayContext) 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("TOOL", msg.Text, true)
}
return h.renderToolMessage(&tool, dc)
}
// renderToolMessage renders a tool message with appropriate formatting
func (h *AskHandler) renderToolMessage(tool *types.ToolMessage, dc *DisplayContext) error {
switch tool.Tool {
case string(types.ToolTypeEditedExistingFile):
dc.Renderer.RenderMessage("TOOL", fmt.Sprintf("Cline wants to edit file: %s", tool.Path), true)
case string(types.ToolTypeNewFileCreated):
dc.Renderer.RenderMessage("TOOL", fmt.Sprintf("Cline wants to create file: %s", tool.Path), true)
case string(types.ToolTypeReadFile):
dc.Renderer.RenderMessage("TOOL", fmt.Sprintf("Cline wants to read file: %s", tool.Path), true)
case string(types.ToolTypeListFilesTopLevel):
dc.Renderer.RenderMessage("TOOL", fmt.Sprintf("Cline wants to list files in: %s", tool.Path), true)
case string(types.ToolTypeListFilesRecursive):
dc.Renderer.RenderMessage("TOOL", fmt.Sprintf("Cline wants to recursively list files in: %s", tool.Path), true)
case string(types.ToolTypeSearchFiles):
dc.Renderer.RenderMessage("TOOL", fmt.Sprintf("Cline wants to search for '%s' in: %s", tool.Regex, tool.Path), true)
case string(types.ToolTypeWebFetch):
dc.Renderer.RenderMessage("TOOL", fmt.Sprintf("Cline wants to fetch URL: %s", tool.Path), true)
case string(types.ToolTypeListCodeDefinitionNames):
dc.Renderer.RenderMessage("TOOL", fmt.Sprintf("Cline wants to list code definitions for: %s", tool.Path), true)
default:
dc.Renderer.RenderMessage("TOOL", fmt.Sprintf("Cline wants to use tool: %s", tool.Tool), true)
}
// 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) error {
return dc.Renderer.RenderMessage("ERROR", fmt.Sprintf("API Request Failed: %s. Approve to retry request.", msg.Text), true)
}
// handleResumeTask handles resume task requests
func (h *AskHandler) handleResumeTask(msg *types.ClineMessage, dc *DisplayContext) error {
return dc.Renderer.RenderMessage("GEN INFO", "Resuming interrupted task.", true)
}
// handleResumeCompletedTask handles resume completed task requests
func (h *AskHandler) handleResumeCompletedTask(msg *types.ClineMessage, dc *DisplayContext) error {
return dc.Renderer.RenderMessage("GEN INFO", "Resuming completed task.", true)
}
// handleMistakeLimitReached handles mistake limit reached
func (h *AskHandler) handleMistakeLimitReached(msg *types.ClineMessage, dc *DisplayContext) error {
return dc.Renderer.RenderMessage("ERROR", fmt.Sprintf("Mistake Limit Reached: %s. Approval required.", msg.Text), true)
}
// handleAutoApprovalMaxReached handles auto-approval max reached
func (h *AskHandler) handleAutoApprovalMaxReached(msg *types.ClineMessage, dc *DisplayContext) error {
return dc.Renderer.RenderMessage("WARNING", fmt.Sprintf("Auto-approval limit reached: %s. Approval required.", msg.Text), true)
}
// handleBrowserActionLaunch handles browser action launch requests
func (h *AskHandler) handleBrowserActionLaunch(msg *types.ClineMessage, dc *DisplayContext) error {
url := strings.TrimSpace(msg.Text)
return dc.Renderer.RenderMessage("BROWSER", fmt.Sprintf("Cline wants to launch browser and navigate to: %s. Approval required.", url), true)
}
// handleUseMcpServer handles MCP server usage requests
func (h *AskHandler) handleUseMcpServer(msg *types.ClineMessage, dc *DisplayContext) 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("MCP", msg.Text, true)
}
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("MCP",
fmt.Sprintf("Cline wants to %s on the %s MCP server", operation, mcpReq.ServerName), true)
}
// handleNewTask handles new task creation requests
func (h *AskHandler) handleNewTask(msg *types.ClineMessage, dc *DisplayContext) error {
return dc.Renderer.RenderMessage("NEW TASK", fmt.Sprintf("Cline wants to start a new task: %s. Approval required.", msg.Text), true)
}
// handleCondense handles conversation condensing requests
func (h *AskHandler) handleCondense(msg *types.ClineMessage, dc *DisplayContext) error {
return dc.Renderer.RenderMessage("CONDENSE", fmt.Sprintf("Cline wants to condense the conversation: %s. Approval required.", msg.Text), true)
}
// handleReportBug handles bug report requests
func (h *AskHandler) handleReportBug(msg *types.ClineMessage, dc *DisplayContext) 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("BUG REPORT", fmt.Sprintf("Cline wants to create a GitHub issue: %s. Approval required.", msg.Text), true)
}
err := dc.Renderer.RenderMessage("BUG REPORT", "Cline wants to create a GitHub issue:", true)
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) error {
return dc.Renderer.RenderMessage("ASK", msg.Text, true)
}
-126
View File
@@ -1,126 +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 {
if msg.Text == "" {
return nil
}
prefix := "RESPONSE:"
return dc.Renderer.RenderMessage(prefix, msg.Text, true)
}
// 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
)
-462
View File
@@ -1,462 +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)
case string(types.SayTypeError):
return h.handleError(msg, dc)
case string(types.SayTypeAPIReqStarted):
return h.handleAPIReqStarted(msg, dc)
case string(types.SayTypeAPIReqFinished):
return h.handleAPIReqFinished(msg, dc)
case string(types.SayTypeText):
return h.handleText(msg, dc)
case string(types.SayTypeReasoning):
return h.handleReasoning(msg, dc)
case string(types.SayTypeCompletionResult):
return h.handleCompletionResult(msg, dc)
case string(types.SayTypeUserFeedback):
return h.handleUserFeedback(msg, dc)
case string(types.SayTypeUserFeedbackDiff):
return h.handleUserFeedbackDiff(msg, dc)
case string(types.SayTypeAPIReqRetried):
return h.handleAPIReqRetried(msg, dc)
case string(types.SayTypeCommand):
return h.handleCommand(msg, dc)
case string(types.SayTypeCommandOutput):
return h.handleCommandOutput(msg, dc)
case string(types.SayTypeTool):
return h.handleTool(msg, dc)
case string(types.SayTypeShellIntegrationWarning):
return h.handleShellIntegrationWarning(msg, dc)
case string(types.SayTypeBrowserActionLaunch):
return h.handleBrowserActionLaunch(msg, dc)
case string(types.SayTypeBrowserAction):
return h.handleBrowserAction(msg, dc)
case string(types.SayTypeBrowserActionResult):
return h.handleBrowserActionResult(msg, dc)
case string(types.SayTypeMcpServerRequestStarted):
return h.handleMcpServerRequestStarted(msg, dc)
case string(types.SayTypeMcpServerResponse):
return h.handleMcpServerResponse(msg, dc)
case string(types.SayTypeMcpNotification):
return h.handleMcpNotification(msg, dc)
case string(types.SayTypeUseMcpServer):
return h.handleUseMcpServer(msg, dc)
case string(types.SayTypeDiffError):
return h.handleDiffError(msg, dc)
case string(types.SayTypeDeletedAPIReqs):
return h.handleDeletedAPIReqs(msg, dc)
case string(types.SayTypeClineignoreError):
return h.handleClineignoreError(msg, dc)
case string(types.SayTypeCheckpointCreated):
return h.handleCheckpointCreated(msg, dc, timestamp)
case string(types.SayTypeLoadMcpDocumentation):
return h.handleLoadMcpDocumentation(msg, dc)
case string(types.SayTypeInfo):
return h.handleInfo(msg, dc)
case string(types.SayTypeTaskProgress):
return h.handleTaskProgress(msg, dc)
default:
return h.handleDefault(msg, dc)
}
}
// handleTask handles task messages
func (h *SayHandler) handleTask(msg *types.ClineMessage, dc *DisplayContext) error {
return nil
}
// handleError handles error messages
func (h *SayHandler) handleError(msg *types.ClineMessage, dc *DisplayContext) error {
return dc.Renderer.RenderMessage("ERROR", msg.Text, true)
}
// handleAPIReqStarted handles API request started messages
func (h *SayHandler) handleAPIReqStarted(msg *types.ClineMessage, dc *DisplayContext) error {
// Parse API request info
apiInfo := types.APIRequestInfo{Cost: -1}
if err := json.Unmarshal([]byte(msg.Text), &apiInfo); err != nil {
return dc.Renderer.RenderMessage("API INFO", msg.Text, true)
}
// Handle different API request states
if apiInfo.CancelReason != "" {
if apiInfo.CancelReason == "user_cancelled" {
return dc.Renderer.RenderMessage("API INFO", "Request Cancelled", true)
} else if apiInfo.CancelReason == "retries_exhausted" {
return dc.Renderer.RenderMessage("API INFO", "Request Failed (Retries Exhausted)", true)
}
return dc.Renderer.RenderMessage("API INFO", "Streaming Failed", true)
}
if apiInfo.Cost >= 0 {
return dc.Renderer.RenderAPI("request completed", &apiInfo)
}
// Check for retry status
if apiInfo.RetryStatus != nil {
return dc.Renderer.RenderRetry(
apiInfo.RetryStatus.Attempt,
apiInfo.RetryStatus.MaxAttempts,
apiInfo.RetryStatus.DelaySec)
}
return dc.Renderer.RenderAPI("processing request", &apiInfo)
}
// handleAPIReqFinished handles API request finished messages
func (h *SayHandler) handleAPIReqFinished(msg *types.ClineMessage, dc *DisplayContext) 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) error {
if msg.Text == "" {
return nil
}
// Special case for the user's task input
if dc.MessageIndex == 0 {
markdown := formatUserMessage(msg.Text)
rendered := dc.Renderer.RenderMarkdown(markdown)
fmt.Printf("%s", rendered)
fmt.Printf("\n")
return nil
}
// Regular Cline text response
markdown := fmt.Sprintf("### Cline responds\n\n%s", msg.Text)
rendered := dc.Renderer.RenderMarkdown(markdown)
fmt.Printf("\n%s\n", rendered)
return nil
}
// handleReasoning handles reasoning messages
func (h *SayHandler) handleReasoning(msg *types.ClineMessage, dc *DisplayContext) error {
if msg.Text == "" {
return nil
}
markdown := fmt.Sprintf("### Cline is thinking\n\n%s", msg.Text)
rendered := dc.Renderer.RenderMarkdown(markdown)
fmt.Printf("\n%s\n", rendered)
return nil
}
func (h *SayHandler) handleCompletionResult(msg *types.ClineMessage, dc *DisplayContext) error {
text := msg.Text
if strings.HasSuffix(text, "HAS_CHANGES") {
text = strings.TrimSuffix(text, "HAS_CHANGES")
}
markdown := fmt.Sprintf("### Task completed\n\n%s", text)
rendered := dc.Renderer.RenderMarkdown(markdown)
fmt.Printf("\n%s\n", rendered)
return nil
}
func formatUserMessage(text string) string {
lines := strings.Split(text, "\n")
// Wrap each line in backticks
for i, line := range lines {
if line != "" {
lines[i] = fmt.Sprintf("`%s`", line)
}
}
return strings.Join(lines, "\n")
}
// handleUserFeedback handles user feedback messages
func (h *SayHandler) handleUserFeedback(msg *types.ClineMessage, dc *DisplayContext) error {
if msg.Text != "" {
markdown := formatUserMessage(msg.Text)
rendered := dc.Renderer.RenderMarkdown(markdown)
fmt.Printf("%s", rendered)
return nil
} else {
return dc.Renderer.RenderMessage("USER", "[Provided feedback without text]", true)
}
}
// handleUserFeedbackDiff handles user feedback diff messages
func (h *SayHandler) handleUserFeedbackDiff(msg *types.ClineMessage, dc *DisplayContext) error {
var toolMsg types.ToolMessage
if err := json.Unmarshal([]byte(msg.Text), &toolMsg); err != nil {
return dc.Renderer.RenderMessage("USER DIFF", msg.Text, true)
}
message := fmt.Sprintf("User manually edited: %s\n\nDiff:\n%s",
toolMsg.Path,
toolMsg.Diff)
return dc.Renderer.RenderMessage("USER DIFF", message, true)
}
// handleAPIReqRetried handles API request retry messages
func (h *SayHandler) handleAPIReqRetried(msg *types.ClineMessage, dc *DisplayContext) error {
return dc.Renderer.RenderMessage("API INFO", "Retrying request", true)
}
// handleCommand handles command execution announcements
func (h *SayHandler) handleCommand(msg *types.ClineMessage, dc *DisplayContext) error {
if msg.Text == "" {
return nil
}
command := strings.TrimSpace(msg.Text)
markdown := fmt.Sprintf("### Cline wants to run a command: `%s`", command)
rendered := dc.Renderer.RenderMarkdown(markdown)
// Render markdown with syntax highlighting
fmt.Printf("%s\n", rendered)
return nil
}
// handleCommandOutput handles command output messages
func (h *SayHandler) handleCommandOutput(msg *types.ClineMessage, dc *DisplayContext) error {
commandOutput := msg.Text
return dc.Renderer.RenderMessage("TERMINAL", fmt.Sprintf("Current terminal output: %s", commandOutput), true)
}
func (h *SayHandler) handleTool(msg *types.ClineMessage, dc *DisplayContext) error {
var tool types.ToolMessage
if err := json.Unmarshal([]byte(msg.Text), &tool); err != nil {
return dc.Renderer.RenderMessage("TOOL", msg.Text, true)
}
return h.renderToolMessage(&tool, dc)
}
func (h *SayHandler) renderToolMessage(tool *types.ToolMessage, dc *DisplayContext) error {
var markdown string
switch tool.Tool {
case string(types.ToolTypeEditedExistingFile):
markdown = fmt.Sprintf("### Cline edited `%s`", tool.Path)
case string(types.ToolTypeNewFileCreated):
markdown = fmt.Sprintf("### Cline created `%s`", tool.Path)
case string(types.ToolTypeReadFile):
markdown = fmt.Sprintf("### Cline read `%s`", tool.Path)
case string(types.ToolTypeListFilesTopLevel):
markdown = fmt.Sprintf("### Cline listed files in `%s`", tool.Path)
case string(types.ToolTypeListFilesRecursive):
markdown = fmt.Sprintf("### Cline recursively listed files in `%s`", tool.Path)
case string(types.ToolTypeSearchFiles):
markdown = fmt.Sprintf("### Cline searched for '%s' in `%s`", tool.Regex, tool.Path)
case string(types.ToolTypeWebFetch):
markdown = fmt.Sprintf("### Cline fetched `%s`", tool.Path)
case string(types.ToolTypeListCodeDefinitionNames):
markdown = fmt.Sprintf("### Cline listed code definitions in `%s`", tool.Path)
case string(types.ToolTypeSummarizeTask):
markdown = "### Cline condensed the conversation"
default:
markdown = fmt.Sprintf("### Tool: %s", tool.Tool)
}
rendered := dc.Renderer.RenderMarkdown(markdown)
fmt.Printf("\n%s\n", rendered)
// Skip content preview for readFile and webFetch tools
if tool.Tool == string(types.ToolTypeReadFile) || tool.Tool == string(types.ToolTypeWebFetch) {
return nil
}
// For edited files, show the diff if available
if tool.Tool == string(types.ToolTypeEditedExistingFile) && tool.Content != "" {
diffMarkdown := fmt.Sprintf("```diff\n%s\n```", tool.Content)
diffRendered := dc.Renderer.RenderMarkdown(diffMarkdown)
fmt.Printf("\n%s\n", diffRendered)
return nil
}
// Show content preview for other tools, 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) error {
return dc.Renderer.RenderMessage("WARNING", "Shell Integration Unavailable - Cline won't be able to view the command's output.", true)
}
// handleBrowserActionLaunch handles browser action launch messages
func (h *SayHandler) handleBrowserActionLaunch(msg *types.ClineMessage, dc *DisplayContext) error {
url := msg.Text
if url == "" {
return nil
}
return dc.Renderer.RenderMessage("BROWSER", fmt.Sprintf("Launching browser at: %s", url), true)
}
// handleBrowserAction handles browser action messages
func (h *SayHandler) handleBrowserAction(msg *types.ClineMessage, dc *DisplayContext) 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("BROWSER", msg.Text, true)
}
// Special handling for type action
if actionData.Action == "type" && actionData.Text != "" {
actionText := fmt.Sprintf("type '%s'", actionData.Text)
return dc.Renderer.RenderMessage("BROWSER", fmt.Sprintf("Next action: %s", actionText), true)
}
// Special handling for click action
if actionData.Action == "click" && actionData.Coordinate != "" {
actionText := fmt.Sprintf("click (%s)", actionData.Coordinate)
return dc.Renderer.RenderMessage("BROWSER", fmt.Sprintf("Next action: %s", actionText), true)
}
// Generic handling for all other actions
return dc.Renderer.RenderMessage("BROWSER", fmt.Sprintf("Next action: %s", actionData.Action), true)
}
// handleBrowserActionResult handles browser action result messages
func (h *SayHandler) handleBrowserActionResult(msg *types.ClineMessage, dc *DisplayContext) 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("BROWSER", "Action completed", true)
}
// If we have logs, include them in the message
if result.Logs != "" {
return dc.Renderer.RenderMessage("BROWSER", fmt.Sprintf("Action completed with logs: '%s'", result.Logs), true)
}
// Default case
return dc.Renderer.RenderMessage("BROWSER", "Action completed", true)
}
// handleMcpServerRequestStarted handles MCP server request started messages
func (h *SayHandler) handleMcpServerRequestStarted(msg *types.ClineMessage, dc *DisplayContext) error {
return dc.Renderer.RenderMessage("MCP", "Sending request to server", true)
}
// handleMcpServerResponse handles MCP server response messages
func (h *SayHandler) handleMcpServerResponse(msg *types.ClineMessage, dc *DisplayContext) error {
return dc.Renderer.RenderMessage("MCP", fmt.Sprintf("Server response: %s", msg.Text), true)
}
// handleMcpNotification handles MCP notification messages
func (h *SayHandler) handleMcpNotification(msg *types.ClineMessage, dc *DisplayContext) error {
return dc.Renderer.RenderMessage("MCP", fmt.Sprintf("Server notification: %s", msg.Text), true)
}
// handleUseMcpServer handles MCP server usage messages
func (h *SayHandler) handleUseMcpServer(msg *types.ClineMessage, dc *DisplayContext) error {
return dc.Renderer.RenderMessage("MCP", "Server operation approved", true)
}
// handleDiffError handles diff error messages
func (h *SayHandler) handleDiffError(msg *types.ClineMessage, dc *DisplayContext) error {
return dc.Renderer.RenderMessage("WARNING", "Diff Edit Failure - The model used an invalid diff edit format or used search patterns that don't match anything in the file.", true)
}
// handleDeletedAPIReqs handles deleted API requests messages
func (h *SayHandler) handleDeletedAPIReqs(msg *types.ClineMessage, dc *DisplayContext) error {
// This message includes api metrics of deleted messages, which we do not log
return dc.Renderer.RenderMessage("GEN INFO", "Checkpoint restored", true)
}
// handleClineignoreError handles .clineignore error messages
func (h *SayHandler) handleClineignoreError(msg *types.ClineMessage, dc *DisplayContext) error {
return dc.Renderer.RenderMessage("WARNING", fmt.Sprintf("Access Denied - Cline tried to access %s which is blocked by the .clineignore file", msg.Text), true)
}
func (h *SayHandler) handleCheckpointCreated(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
return dc.Renderer.RenderCheckpointMessage(timestamp, "GEN INFO", msg.Timestamp)
}
// handleLoadMcpDocumentation handles load MCP documentation messages
func (h *SayHandler) handleLoadMcpDocumentation(msg *types.ClineMessage, dc *DisplayContext) error {
return dc.Renderer.RenderMessage("GEN INFO", "Loading MCP documentation", true)
}
// handleInfo handles info messages
func (h *SayHandler) handleInfo(msg *types.ClineMessage, dc *DisplayContext) error {
return nil
}
// handleTaskProgress handles task progress messages
func (h *SayHandler) handleTaskProgress(msg *types.ClineMessage, dc *DisplayContext) error {
if msg.Text == "" {
return nil
}
markdown := fmt.Sprintf("### Progress\n\n%s", msg.Text)
rendered := dc.Renderer.RenderMarkdown(markdown)
fmt.Printf("\n%s\n", rendered)
return nil
}
// handleDefault handles unknown SAY message types
func (h *SayHandler) handleDefault(msg *types.ClineMessage, dc *DisplayContext) error {
return dc.Renderer.RenderMessage("SAY", msg.Text, true)
}
-388
View File
@@ -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
}
-366
View File
@@ -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()
}
-551
View File
@@ -1,551 +0,0 @@
package cli
import (
"context"
"fmt"
"io"
"os"
"slices"
"strconv"
"strings"
"github.com/cline/cli/pkg/cli/global"
"github.com/cline/cli/pkg/cli/task"
"github.com/spf13/cobra"
)
func NewTaskCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "task",
Aliases: []string{"t"},
Short: "Manage Cline tasks",
Long: `Create, monitor, and manage Cline AI tasks.`,
}
cmd.AddCommand(newTaskNewCommand())
cmd.AddCommand(newTaskOneshotCommand())
cmd.AddCommand(newTaskCancelCommand())
cmd.AddCommand(newTaskFollowCommand())
cmd.AddCommand(NewTaskSendCommand())
cmd.AddCommand(newTaskViewCommand())
cmd.AddCommand(newTaskListCommand())
cmd.AddCommand(newTaskResumeCommand())
cmd.AddCommand(newTaskRestoreCommand())
return cmd
}
var taskManager *task.Manager
func ensureTaskManager(ctx context.Context, address string) error {
if taskManager == nil || (address != "" && taskManager.GetCurrentInstance() != address) {
var err error
var instanceAddress string
if address != "" {
// Ensure instance exists at the specified address
if err := ensureInstanceAtAddress(ctx, address); err != nil {
return fmt.Errorf("failed to ensure instance at address %s: %w", address, err)
}
taskManager, err = task.NewManagerForAddress(ctx, address)
instanceAddress = address
} else {
// Ensure default instance exists
if err := global.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)
}
func newTaskNewCommand() *cobra.Command {
var (
images []string
files []string
wait bool
workspaces []string
address string
mode string
settings []string
yolo bool
)
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)
}
// Inject yolo_mode_toggled setting if --yolo flag is set
// Will append to the -s settings to be parsed by the settings parser logic.
// If the yoloMode is also set in the settings, this will override that, since it will be set last.
if yolo {
settings = append(settings, "yolo_mode_toggled=true")
}
// Create the task
taskID, err := taskManager.CreateTask(ctx, prompt, images, files, workspaces, settings)
if err != nil {
return fmt.Errorf("failed to create task: %w", err)
}
fmt.Printf("Task created successfully with ID: %s\n", taskID)
// Wait for completion if requested
if wait {
return taskManager.FollowConversation(ctx, 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().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)")
cmd.Flags().StringSliceVarP(&settings, "setting", "s", nil, "task settings (key=value format, e.g., -s aws-region=us-west-2 -s mode=act)")
cmd.Flags().BoolVarP(&yolo, "yolo", "y", false, "enable yolo mode (non-interactive)")
return cmd
}
func newTaskOneshotCommand() *cobra.Command {
var (
images []string
files []string
workspaces []string
address string
settings []string
)
cmd := &cobra.Command{
Use: "oneshot <prompt>",
Aliases: []string{"o"},
Short: "Create a task in yolo+plan mode and view until completion",
Long: `Creates a new task in yolo mode (non-interactive) and plan mode, then streams the conversation until completion.`,
Args: cobra.MinimumNArgs(0),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
// Get prompt from args/stdin
prompt, err := getContentFromStdinAndArgs(args)
if err != nil {
return fmt.Errorf("failed to read prompt: %w", err)
}
if prompt == "" {
return fmt.Errorf("prompt required: provide as argument or pipe via stdin")
}
// Ensure task manager
if err := ensureTaskManager(ctx, address); err != nil {
return err
}
// Set mode to plan
if err := taskManager.SetMode(ctx, "plan", nil, nil, nil); err != nil {
return fmt.Errorf("failed to set plan mode: %w", err)
}
fmt.Println("Mode set to: plan")
// Inject yolo mode into settings
settings = append(settings, "yolo_mode_toggled=true")
// Create task
taskID, err := taskManager.CreateTask(ctx, prompt, images, files, workspaces, settings)
if err != nil {
return fmt.Errorf("failed to create task: %w", err)
}
fmt.Printf("Task created in yolo+plan mode (ID: %s)\n", taskID)
fmt.Printf("Using instance: %s\n", taskManager.GetCurrentInstance())
// Follow until completion
return taskManager.FollowConversationUntilCompletion(ctx)
},
}
cmd.Flags().StringSliceVarP(&images, "image", "i", nil, "attach image files")
cmd.Flags().StringSliceVarP(&files, "file", "f", nil, "attach files")
cmd.Flags().StringSliceVarP(&workspaces, "workdir", "w", nil, "workdir directory paths")
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
cmd.Flags().StringSliceVarP(&settings, "setting", "s", nil, "task settings (key=value format, e.g., -s model=claude)")
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
}
return taskManager.FollowConversation(ctx, taskManager.GetCurrentInstance())
},
}
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(&current, "current", "c", false, "output current conversation without following")
cmd.Flags().BoolVarP(&summary, "summary", "s", false, "outputs only the completion summary")
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
return cmd
}
func newTaskListCommand() *cobra.Command {
var address string
cmd := &cobra.Command{
Use: "list",
Aliases: []string{"l"},
Short: "List recent task history",
Long: `Display recent tasks from task history.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
// Ensure task manager is initialized
if err := ensureTaskManager(ctx, address); err != nil {
return err
}
fmt.Printf("Using instance: %s\n", taskManager.GetCurrentInstance())
return taskManager.ListTasks(ctx)
},
}
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
return cmd
}
func newTaskResumeCommand() *cobra.Command {
var address string
cmd := &cobra.Command{
Use: "resume <task-id>",
Aliases: []string{"r"},
Short: "Resume a task by ID",
Long: `Resume an existing task by ID.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
taskID := args[0]
// Ensure task manager is initialized
if err := ensureTaskManager(ctx, address); err != nil {
return err
}
fmt.Printf("Using instance: %s\n", taskManager.GetCurrentInstance())
return taskManager.ResumeTask(ctx, taskID)
},
}
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
return cmd
}
func newTaskRestoreCommand() *cobra.Command {
var (
restoreType string
address string
)
cmd := &cobra.Command{
Use: "restore <checkpoint-id>",
Short: "Restore task to a specific checkpoint",
Long: `Restore the current task to a specific checkpoint by checkpoint ID (timestamp) and by type.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
checkpointID := args[0]
// Convert checkpoint ID string to int64
id, err := strconv.ParseInt(checkpointID, 10, 64)
if err != nil {
return fmt.Errorf("invalid checkpoint ID '%s': must be a valid number", checkpointID)
}
validTypes := []string{"task", "workspace", "taskAndWorkspace"}
if !slices.Contains(validTypes, restoreType) {
return fmt.Errorf("invalid restore type '%s': must be one of [task, workspace, taskAndWorkspace]", restoreType)
}
// Ensure task manager is initialized
if err := ensureTaskManager(ctx, address); err != nil {
return err
}
// Validate checkpoint exists before attempting restore
if err := taskManager.ValidateCheckpointExists(ctx, id); err != nil {
return err
}
fmt.Printf("Using instance: %s\n", taskManager.GetCurrentInstance())
fmt.Printf("Restoring to checkpoint %d (type: %s)\n", id, restoreType)
if err := taskManager.RestoreCheckpoint(ctx, id, restoreType); err != nil {
return fmt.Errorf("failed to restore checkpoint: %w", err)
}
fmt.Println("Checkpoint restored successfully")
return nil
},
}
cmd.Flags().StringVarP(&restoreType, "type", "t", "task", "Restore type (task, workspace, taskAndWorkspace)")
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
return cmd
}
// getContentFromStdinAndArgs reads content from both command line args and stdin, and combines them
func getContentFromStdinAndArgs(args []string) (string, error) {
var content strings.Builder
// Add command line args first (if any)
if len(args) > 0 {
content.WriteString(strings.Join(args, " "))
}
// Check if stdin has data
stat, err := os.Stdin.Stat()
if err != nil {
return "", fmt.Errorf("failed to stat stdin: %w", err)
}
// Check if data is being piped to stdin
if (stat.Mode() & os.ModeCharDevice) == 0 {
stdinBytes, err := io.ReadAll(os.Stdin)
if err != nil {
return "", fmt.Errorf("failed to read from stdin: %w", err)
}
stdinContent := strings.TrimSpace(string(stdinBytes))
if stdinContent != "" {
if content.Len() > 0 {
content.WriteString(" ")
}
content.WriteString(stdinContent)
}
}
return content.String(), nil
}
// CleanupTaskManager cleans up the task manager resources
func CleanupTaskManager() {
if taskManager != nil {
taskManager.Cleanup()
}
}
File diff suppressed because it is too large Load Diff
-654
View File
@@ -1,654 +0,0 @@
package task
import (
"fmt"
"strconv"
"strings"
"github.com/cline/grpc-go/cline"
)
func ParseTaskSettings(settingsFlags []string) (*cline.TaskSettings, error) {
if len(settingsFlags) == 0 {
return nil, nil
}
settings := &cline.TaskSettings{}
nestedSettings := make(map[string]map[string]string)
for _, flag := range settingsFlags {
// Parse key=value
parts := strings.SplitN(flag, "=", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("invalid setting format '%s': expected key=value", flag)
}
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
// Convert kebab-case to snake_case
key = kebabToSnake(key)
// Check if this is a nested setting (contains a dot)
if strings.Contains(key, ".") {
dotParts := strings.SplitN(key, ".", 2)
parentField := dotParts[0]
childField := dotParts[1]
if nestedSettings[parentField] == nil {
nestedSettings[parentField] = make(map[string]string)
}
nestedSettings[parentField][childField] = value
} else {
// Simple field - set directly
if err := setSimpleField(settings, key, value); err != nil {
return nil, fmt.Errorf("error setting field '%s': %w", key, err)
}
}
}
// Process nested settings
for parentField, childFields := range nestedSettings {
if err := setNestedField(settings, parentField, childFields); err != nil {
return nil, fmt.Errorf("error setting nested field '%s': %w", parentField, err)
}
}
return settings, nil
}
// kebabToSnake converts kebab-case to snake_case
func kebabToSnake(s string) string {
return strings.ReplaceAll(s, "-", "_")
}
// Pointer helper functions for optional protobuf fields
func strPtr(s string) *string { return &s }
func boolPtr(b bool) *bool { return &b }
func int32Ptr(i int32) *int32 { return &i }
func int64Ptr(i int64) *int64 { return &i }
func float64Ptr(f float64) *float64 { return &f }
// setSimpleField sets a simple (non-nested) field on TaskSettings
func setSimpleField(settings *cline.TaskSettings, key, value string) error {
switch key {
// String fields
case "aws_region":
settings.AwsRegion = strPtr(value)
case "aws_bedrock_endpoint":
settings.AwsBedrockEndpoint = strPtr(value)
case "aws_profile":
settings.AwsProfile = strPtr(value)
case "aws_authentication":
settings.AwsAuthentication = strPtr(value)
case "vertex_project_id":
settings.VertexProjectId = strPtr(value)
case "vertex_region":
settings.VertexRegion = strPtr(value)
case "requesty_base_url":
settings.RequestyBaseUrl = strPtr(value)
case "open_ai_base_url":
settings.OpenAiBaseUrl = strPtr(value)
case "ollama_base_url":
settings.OllamaBaseUrl = strPtr(value)
case "ollama_api_options_ctx_num":
settings.OllamaApiOptionsCtxNum = strPtr(value)
case "lm_studio_base_url":
settings.LmStudioBaseUrl = strPtr(value)
case "lm_studio_max_tokens":
settings.LmStudioMaxTokens = strPtr(value)
case "anthropic_base_url":
settings.AnthropicBaseUrl = strPtr(value)
case "gemini_base_url":
settings.GeminiBaseUrl = strPtr(value)
case "azure_api_version":
settings.AzureApiVersion = strPtr(value)
case "open_router_provider_sorting":
settings.OpenRouterProviderSorting = strPtr(value)
case "lite_llm_base_url":
settings.LiteLlmBaseUrl = strPtr(value)
case "qwen_api_line":
settings.QwenApiLine = strPtr(value)
case "moonshot_api_line":
settings.MoonshotApiLine = strPtr(value)
case "zai_api_line":
settings.ZaiApiLine = strPtr(value)
case "telemetry_setting":
settings.TelemetrySetting = strPtr(value)
case "asksage_api_url":
settings.AsksageApiUrl = strPtr(value)
case "default_terminal_profile":
settings.DefaultTerminalProfile = strPtr(value)
case "sap_ai_core_token_url":
settings.SapAiCoreTokenUrl = strPtr(value)
case "sap_ai_core_base_url":
settings.SapAiCoreBaseUrl = strPtr(value)
case "sap_ai_resource_group":
settings.SapAiResourceGroup = strPtr(value)
case "claude_code_path":
settings.ClaudeCodePath = strPtr(value)
case "qwen_code_oauth_path":
settings.QwenCodeOauthPath = strPtr(value)
case "preferred_language":
settings.PreferredLanguage = strPtr(value)
case "custom_prompt":
settings.CustomPrompt = strPtr(value)
case "dify_base_url":
settings.DifyBaseUrl = strPtr(value)
case "oca_base_url":
settings.OcaBaseUrl = strPtr(value)
case "plan_mode_api_model_id":
settings.PlanModeApiModelId = strPtr(value)
case "plan_mode_reasoning_effort":
settings.PlanModeReasoningEffort = strPtr(value)
case "plan_mode_aws_bedrock_custom_model_base_id":
settings.PlanModeAwsBedrockCustomModelBaseId = strPtr(value)
case "plan_mode_open_router_model_id":
settings.PlanModeOpenRouterModelId = strPtr(value)
case "plan_mode_open_ai_model_id":
settings.PlanModeOpenAiModelId = strPtr(value)
case "plan_mode_ollama_model_id":
settings.PlanModeOllamaModelId = strPtr(value)
case "plan_mode_lm_studio_model_id":
settings.PlanModeLmStudioModelId = strPtr(value)
case "plan_mode_lite_llm_model_id":
settings.PlanModeLiteLlmModelId = strPtr(value)
case "plan_mode_requesty_model_id":
settings.PlanModeRequestyModelId = strPtr(value)
case "plan_mode_together_model_id":
settings.PlanModeTogetherModelId = strPtr(value)
case "plan_mode_fireworks_model_id":
settings.PlanModeFireworksModelId = strPtr(value)
case "plan_mode_sap_ai_core_model_id":
settings.PlanModeSapAiCoreModelId = strPtr(value)
case "plan_mode_sap_ai_core_deployment_id":
settings.PlanModeSapAiCoreDeploymentId = strPtr(value)
case "plan_mode_groq_model_id":
settings.PlanModeGroqModelId = strPtr(value)
case "plan_mode_baseten_model_id":
settings.PlanModeBasetenModelId = strPtr(value)
case "plan_mode_hugging_face_model_id":
settings.PlanModeHuggingFaceModelId = strPtr(value)
case "plan_mode_huawei_cloud_maas_model_id":
settings.PlanModeHuaweiCloudMaasModelId = strPtr(value)
case "plan_mode_oca_model_id":
settings.PlanModeOcaModelId = strPtr(value)
case "plan_mode_vercel_ai_gateway_model_id":
settings.PlanModeVercelAiGatewayModelId = strPtr(value)
case "act_mode_api_model_id":
settings.ActModeApiModelId = strPtr(value)
case "act_mode_reasoning_effort":
settings.ActModeReasoningEffort = strPtr(value)
case "act_mode_aws_bedrock_custom_model_base_id":
settings.ActModeAwsBedrockCustomModelBaseId = strPtr(value)
case "act_mode_open_router_model_id":
settings.ActModeOpenRouterModelId = strPtr(value)
case "act_mode_open_ai_model_id":
settings.ActModeOpenAiModelId = strPtr(value)
case "act_mode_ollama_model_id":
settings.ActModeOllamaModelId = strPtr(value)
case "act_mode_lm_studio_model_id":
settings.ActModeLmStudioModelId = strPtr(value)
case "act_mode_lite_llm_model_id":
settings.ActModeLiteLlmModelId = strPtr(value)
case "act_mode_requesty_model_id":
settings.ActModeRequestyModelId = strPtr(value)
case "act_mode_together_model_id":
settings.ActModeTogetherModelId = strPtr(value)
case "act_mode_fireworks_model_id":
settings.ActModeFireworksModelId = strPtr(value)
case "act_mode_sap_ai_core_model_id":
settings.ActModeSapAiCoreModelId = strPtr(value)
case "act_mode_sap_ai_core_deployment_id":
settings.ActModeSapAiCoreDeploymentId = strPtr(value)
case "act_mode_groq_model_id":
settings.ActModeGroqModelId = strPtr(value)
case "act_mode_baseten_model_id":
settings.ActModeBasetenModelId = strPtr(value)
case "act_mode_hugging_face_model_id":
settings.ActModeHuggingFaceModelId = strPtr(value)
case "act_mode_huawei_cloud_maas_model_id":
settings.ActModeHuaweiCloudMaasModelId = strPtr(value)
case "act_mode_oca_model_id":
settings.ActModeOcaModelId = strPtr(value)
case "act_mode_vercel_ai_gateway_model_id":
settings.ActModeVercelAiGatewayModelId = strPtr(value)
// Boolean fields
case "aws_use_cross_region_inference":
val, err := parseBool(value)
if err != nil {
return err
}
settings.AwsUseCrossRegionInference = boolPtr(val)
case "aws_bedrock_use_prompt_cache":
val, err := parseBool(value)
if err != nil {
return err
}
settings.AwsBedrockUsePromptCache = boolPtr(val)
case "aws_use_profile":
val, err := parseBool(value)
if err != nil {
return err
}
settings.AwsUseProfile = boolPtr(val)
case "lite_llm_use_prompt_cache":
val, err := parseBool(value)
if err != nil {
return err
}
settings.LiteLlmUsePromptCache = boolPtr(val)
case "plan_act_separate_models_setting":
val, err := parseBool(value)
if err != nil {
return err
}
settings.PlanActSeparateModelsSetting = boolPtr(val)
case "enable_checkpoints_setting":
val, err := parseBool(value)
if err != nil {
return err
}
settings.EnableCheckpointsSetting = boolPtr(val)
case "sap_ai_core_use_orchestration_mode":
val, err := parseBool(value)
if err != nil {
return err
}
settings.SapAiCoreUseOrchestrationMode = boolPtr(val)
case "strict_plan_mode_enabled":
val, err := parseBool(value)
if err != nil {
return err
}
settings.StrictPlanModeEnabled = boolPtr(val)
case "yolo_mode_toggled":
val, err := parseBool(value)
if err != nil {
return err
}
settings.YoloModeToggled = boolPtr(val)
case "use_auto_condense":
val, err := parseBool(value)
if err != nil {
return err
}
settings.UseAutoCondense = boolPtr(val)
case "plan_mode_aws_bedrock_custom_selected":
val, err := parseBool(value)
if err != nil {
return err
}
settings.PlanModeAwsBedrockCustomSelected = boolPtr(val)
case "act_mode_aws_bedrock_custom_selected":
val, err := parseBool(value)
if err != nil {
return err
}
settings.ActModeAwsBedrockCustomSelected = boolPtr(val)
// Integer fields
case "request_timeout_ms":
val, err := parseInt32(value)
if err != nil {
return err
}
settings.RequestTimeoutMs = int32Ptr(val)
case "shell_integration_timeout":
val, err := parseInt32(value)
if err != nil {
return err
}
settings.ShellIntegrationTimeout = int32Ptr(val)
case "terminal_output_line_limit":
val, err := parseInt32(value)
if err != nil {
return err
}
settings.TerminalOutputLineLimit = int32Ptr(val)
case "fireworks_model_max_completion_tokens":
val, err := parseInt32(value)
if err != nil {
return err
}
settings.FireworksModelMaxCompletionTokens = int32Ptr(val)
case "fireworks_model_max_tokens":
val, err := parseInt32(value)
if err != nil {
return err
}
settings.FireworksModelMaxTokens = int32Ptr(val)
// Int64 fields
case "plan_mode_thinking_budget_tokens":
val, err := parseInt64(value)
if err != nil {
return err
}
settings.PlanModeThinkingBudgetTokens = int64Ptr(val)
case "act_mode_thinking_budget_tokens":
val, err := parseInt64(value)
if err != nil {
return err
}
settings.ActModeThinkingBudgetTokens = int64Ptr(val)
// Double fields
case "auto_condense_threshold":
val, err := parseFloat64(value)
if err != nil {
return err
}
settings.AutoCondenseThreshold = float64Ptr(val)
// Enum fields
// Note: We can use &val directly for enums because the parser functions return a new local variable.
// This is different from using &value (the loop variable), which would cause all fields to share
// the same memory address.
case "openai_reasoning_effort":
val, err := parseOpenaiReasoningEffort(value)
if err != nil {
return err
}
settings.OpenaiReasoningEffort = &val
case "mode":
val, err := parsePlanActMode(value)
if err != nil {
return err
}
settings.Mode = &val
case "plan_mode_api_provider":
val, err := parseApiProvider(value)
if err != nil {
return err
}
settings.PlanModeApiProvider = &val
case "act_mode_api_provider":
val, err := parseApiProvider(value)
if err != nil {
return err
}
settings.ActModeApiProvider = &val
default:
return fmt.Errorf("unsupported field '%s'", key)
}
return nil
}
// setNestedField sets a nested field on TaskSettings
// Currently supports: auto_approval_settings, browser_settings
func setNestedField(settings *cline.TaskSettings, parentField string, childFields map[string]string) error {
switch parentField {
case "auto_approval_settings":
if settings.AutoApprovalSettings == nil {
settings.AutoApprovalSettings = &cline.AutoApprovalSettings{}
}
return setAutoApprovalSettings(settings.AutoApprovalSettings, childFields)
case "browser_settings":
if settings.BrowserSettings == nil {
settings.BrowserSettings = &cline.BrowserSettings{}
}
return setBrowserSettings(settings.BrowserSettings, childFields)
default:
return fmt.Errorf("unsupported nested field '%s' (complex nested types are not supported via -s flags)", parentField)
}
}
// setAutoApprovalSettings sets fields on AutoApprovalSettings
func setAutoApprovalSettings(settings *cline.AutoApprovalSettings, fields map[string]string) error {
for key, value := range fields {
switch key {
case "enabled":
val, err := parseBool(value)
if err != nil {
return err
}
settings.Enabled = val
case "max_requests":
val, err := parseInt32(value)
if err != nil {
return err
}
settings.MaxRequests = val
case "enable_notifications":
val, err := parseBool(value)
if err != nil {
return err
}
settings.EnableNotifications = val
case "actions":
return fmt.Errorf("auto_approval_settings.actions requires nested dot notation (e.g., auto-approval-settings.actions.read-files=true)")
default:
// Check if this is an action field (actions.*)
if strings.HasPrefix(key, "actions.") {
actionField := strings.TrimPrefix(key, "actions.")
if settings.Actions == nil {
settings.Actions = &cline.AutoApprovalActions{}
}
if err := setAutoApprovalAction(settings.Actions, actionField, value); err != nil {
return err
}
// Continue processing other fields
} else {
return fmt.Errorf("unsupported auto_approval_settings field '%s'", key)
}
}
}
return nil
}
// setAutoApprovalAction sets fields on AutoApprovalActions
func setAutoApprovalAction(actions *cline.AutoApprovalActions, key, value string) error {
val, err := parseBool(value)
if err != nil {
return err
}
switch key {
case "read_files":
actions.ReadFiles = val
case "read_files_externally":
actions.ReadFilesExternally = val
case "edit_files":
actions.EditFiles = val
case "edit_files_externally":
actions.EditFilesExternally = val
case "execute_safe_commands":
actions.ExecuteSafeCommands = val
case "execute_all_commands":
actions.ExecuteAllCommands = val
case "use_browser":
actions.UseBrowser = val
case "use_mcp":
actions.UseMcp = val
default:
return fmt.Errorf("unsupported auto_approval_actions field '%s'", key)
}
return nil
}
// setBrowserSettings sets fields on BrowserSettings
func setBrowserSettings(settings *cline.BrowserSettings, fields map[string]string) error {
for key, value := range fields {
switch key {
case "viewport_width":
val, err := parseInt32(value)
if err != nil {
return err
}
if settings.Viewport == nil {
settings.Viewport = &cline.Viewport{}
}
settings.Viewport.Width = val
case "viewport_height":
val, err := parseInt32(value)
if err != nil {
return err
}
if settings.Viewport == nil {
settings.Viewport = &cline.Viewport{}
}
settings.Viewport.Height = val
default:
return fmt.Errorf("unsupported browser_settings field '%s'", key)
}
}
return nil
}
// Type parsing helpers
func parseBool(value string) (bool, error) {
lower := strings.ToLower(value)
switch lower {
case "true", "t", "yes", "y", "1":
return true, nil
case "false", "f", "no", "n", "0":
return false, nil
default:
return false, fmt.Errorf("invalid boolean value '%s': expected true/false", value)
}
}
func parseInt32(value string) (int32, error) {
val, err := strconv.ParseInt(value, 10, 32)
if err != nil {
return 0, fmt.Errorf("invalid integer value '%s': %w", value, err)
}
return int32(val), nil
}
func parseInt64(value string) (int64, error) {
val, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return 0, fmt.Errorf("invalid integer value '%s': %w", value, err)
}
return val, nil
}
func parseFloat64(value string) (float64, error) {
val, err := strconv.ParseFloat(value, 64)
if err != nil {
return 0, fmt.Errorf("invalid float value '%s': %w", value, err)
}
return val, nil
}
// Enum parsing helpers
func parseOpenaiReasoningEffort(value string) (cline.OpenaiReasoningEffort, error) {
lower := strings.ToLower(value)
switch lower {
case "low":
return cline.OpenaiReasoningEffort_LOW, nil
case "medium":
return cline.OpenaiReasoningEffort_MEDIUM, nil
case "high":
return cline.OpenaiReasoningEffort_HIGH, nil
default:
return cline.OpenaiReasoningEffort_LOW, fmt.Errorf("invalid openai_reasoning_effort '%s': expected low/medium/high", value)
}
}
func parsePlanActMode(value string) (cline.PlanActMode, error) {
lower := strings.ToLower(value)
switch lower {
case "plan":
return cline.PlanActMode_PLAN, nil
case "act":
return cline.PlanActMode_ACT, nil
default:
return cline.PlanActMode_ACT, fmt.Errorf("invalid mode '%s': expected plan/act", value)
}
}
func parseApiProvider(value string) (cline.ApiProvider, error) {
lower := strings.ToLower(value)
switch lower {
case "anthropic":
return cline.ApiProvider_ANTHROPIC, nil
case "openrouter":
return cline.ApiProvider_OPENROUTER, nil
case "bedrock":
return cline.ApiProvider_BEDROCK, nil
case "vertex":
return cline.ApiProvider_VERTEX, nil
case "openai":
return cline.ApiProvider_OPENAI, nil
case "ollama":
return cline.ApiProvider_OLLAMA, nil
case "lmstudio":
return cline.ApiProvider_LMSTUDIO, nil
case "gemini":
return cline.ApiProvider_GEMINI, nil
case "openai_native":
return cline.ApiProvider_OPENAI_NATIVE, nil
case "requesty":
return cline.ApiProvider_REQUESTY, nil
case "together":
return cline.ApiProvider_TOGETHER, nil
case "deepseek":
return cline.ApiProvider_DEEPSEEK, nil
case "qwen":
return cline.ApiProvider_QWEN, nil
case "doubao":
return cline.ApiProvider_DOUBAO, nil
case "mistral":
return cline.ApiProvider_MISTRAL, nil
case "vscode_lm":
return cline.ApiProvider_VSCODE_LM, nil
case "cline":
return cline.ApiProvider_CLINE, nil
case "litellm":
return cline.ApiProvider_LITELLM, nil
case "nebius":
return cline.ApiProvider_NEBIUS, nil
case "fireworks":
return cline.ApiProvider_FIREWORKS, nil
case "asksage":
return cline.ApiProvider_ASKSAGE, nil
case "xai", "grok":
return cline.ApiProvider_XAI, nil
case "sambanova":
return cline.ApiProvider_SAMBANOVA, nil
case "cerebras":
return cline.ApiProvider_CEREBRAS, nil
case "groq":
return cline.ApiProvider_GROQ, nil
case "sapaicore", "sap_ai_core":
return cline.ApiProvider_SAPAICORE, nil
case "claude_code":
return cline.ApiProvider_CLAUDE_CODE, nil
case "moonshot":
return cline.ApiProvider_MOONSHOT, nil
case "huggingface":
return cline.ApiProvider_HUGGINGFACE, nil
case "huawei_cloud_maas":
return cline.ApiProvider_HUAWEI_CLOUD_MAAS, nil
case "baseten":
return cline.ApiProvider_BASETEN, nil
case "zai":
return cline.ApiProvider_ZAI, nil
case "vercel_ai_gateway":
return cline.ApiProvider_VERCEL_AI_GATEWAY, nil
case "qwen_code":
return cline.ApiProvider_QWEN_CODE, nil
case "dify":
return cline.ApiProvider_DIFY, nil
case "oca":
return cline.ApiProvider_OCA, nil
default:
return cline.ApiProvider_ANTHROPIC, fmt.Errorf("invalid api_provider '%s'", value)
}
}
// Note: message types not supported via -s flags:
// - OpenRouterModelInfo, OpenAiCompatibleModelInfo, LiteLLMModelInfo, OcaModelInfo
// - LanguageModelChatSelector
// - DictationSettings
// - FocusChainSettings
-41
View File
@@ -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)
}
-328
View File
@@ -1,328 +0,0 @@
package types
import (
"encoding/json"
"fmt"
"strconv"
"time"
"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"`
LastCheckpointHash string `json:"lastCheckpointHash,omitempty"`
IsCheckpointCheckedOut bool `json:"isCheckpointCheckedOut,omitempty"`
IsOperationOutsideWorkspace bool `json:"isOperationOutsideWorkspace,omitempty"`
}
// MessageType represents the type of message
type MessageType string
const (
MessageTypeAsk MessageType = "ask"
MessageTypeSay MessageType = "say"
)
// AskType represents different types of ASK messages
type AskType string
const (
AskTypeFollowup AskType = "followup"
AskTypePlanModeRespond AskType = "plan_mode_respond"
AskTypeCommand AskType = "command"
AskTypeCommandOutput AskType = "command_output"
AskTypeCompletionResult AskType = "completion_result"
AskTypeTool AskType = "tool"
AskTypeAPIReqFailed AskType = "api_req_failed"
AskTypeResumeTask AskType = "resume_task"
AskTypeResumeCompletedTask AskType = "resume_completed_task"
AskTypeMistakeLimitReached AskType = "mistake_limit_reached"
AskTypeAutoApprovalMaxReached AskType = "auto_approval_max_req_reached"
AskTypeBrowserActionLaunch AskType = "browser_action_launch"
AskTypeUseMcpServer AskType = "use_mcp_server"
AskTypeNewTask AskType = "new_task"
AskTypeCondense AskType = "condense"
AskTypeReportBug AskType = "report_bug"
)
// SayType represents different types of SAY messages
type SayType string
const (
SayTypeTask SayType = "task"
SayTypeError SayType = "error"
SayTypeAPIReqStarted SayType = "api_req_started"
SayTypeAPIReqFinished SayType = "api_req_finished"
SayTypeText SayType = "text"
SayTypeReasoning SayType = "reasoning"
SayTypeCompletionResult SayType = "completion_result"
SayTypeUserFeedback SayType = "user_feedback"
SayTypeUserFeedbackDiff SayType = "user_feedback_diff"
SayTypeAPIReqRetried SayType = "api_req_retried"
SayTypeCommand SayType = "command"
SayTypeCommandOutput SayType = "command_output"
SayTypeTool SayType = "tool"
SayTypeShellIntegrationWarning SayType = "shell_integration_warning"
SayTypeBrowserActionLaunch SayType = "browser_action_launch"
SayTypeBrowserAction SayType = "browser_action"
SayTypeBrowserActionResult SayType = "browser_action_result"
SayTypeMcpServerRequestStarted SayType = "mcp_server_request_started"
SayTypeMcpServerResponse SayType = "mcp_server_response"
SayTypeMcpNotification SayType = "mcp_notification"
SayTypeUseMcpServer SayType = "use_mcp_server"
SayTypeDiffError SayType = "diff_error"
SayTypeDeletedAPIReqs SayType = "deleted_api_reqs"
SayTypeClineignoreError SayType = "clineignore_error"
SayTypeCheckpointCreated SayType = "checkpoint_created"
SayTypeLoadMcpDocumentation SayType = "load_mcp_documentation"
SayTypeInfo SayType = "info"
SayTypeTaskProgress SayType = "task_progress"
)
// ToolMessage represents a tool-related message
type ToolMessage struct {
Tool string `json:"tool"`
Path string `json:"path,omitempty"`
Content string `json:"content,omitempty"`
Diff string `json:"diff,omitempty"`
Regex string `json:"regex,omitempty"`
FilePattern string `json:"filePattern,omitempty"`
OperationIsLocatedInWorkspace *bool `json:"operationIsLocatedInWorkspace,omitempty"`
}
// ToolType represents different types of tools
type ToolType string
const (
ToolTypeEditedExistingFile ToolType = "editedExistingFile"
ToolTypeNewFileCreated ToolType = "newFileCreated"
ToolTypeReadFile ToolType = "readFile"
ToolTypeListFilesTopLevel ToolType = "listFilesTopLevel"
ToolTypeListFilesRecursive ToolType = "listFilesRecursive"
ToolTypeListCodeDefinitionNames ToolType = "listCodeDefinitionNames"
ToolTypeSearchFiles ToolType = "searchFiles"
ToolTypeWebFetch ToolType = "webFetch"
ToolTypeSummarizeTask ToolType = "summarizeTask"
)
// AskData represents the parsed structure of an ASK message
type AskData struct {
Question string `json:"question"`
Response string `json:"response"`
Options []string `json:"options,omitempty"`
}
// APIRequestInfo represents API request information
type APIRequestInfo struct {
Request string `json:"request,omitempty"`
TokensIn int `json:"tokensIn,omitempty"`
TokensOut int `json:"tokensOut,omitempty"`
CacheWrites int `json:"cacheWrites,omitempty"`
CacheReads int `json:"cacheReads,omitempty"`
Cost float64 `json:"cost,omitempty"`
CancelReason string `json:"cancelReason,omitempty"`
StreamingFailedMessage string `json:"streamingFailedMessage,omitempty"`
RetryStatus *APIRequestRetryStatus `json:"retryStatus,omitempty"`
}
// APIRequestRetryStatus represents retry status information
type APIRequestRetryStatus struct {
Attempt int `json:"attempt"`
MaxAttempts int `json:"maxAttempts"`
DelaySec int `json:"delaySec"`
ErrorSnippet string `json:"errorSnippet,omitempty"`
}
// GetTimestamp returns a formatted timestamp string
func (m *ClineMessage) GetTimestamp() string {
return time.Unix(m.Timestamp/1000, 0).Format("15:04:05")
}
// IsAsk returns true if this is an ASK message
func (m *ClineMessage) IsAsk() bool {
return m.Type == MessageTypeAsk
}
// IsSay returns true if this is a SAY message
func (m *ClineMessage) IsSay() bool {
return m.Type == MessageTypeSay
}
// GetMessageKey returns a unique key for this message based on timestamp
func (m *ClineMessage) GetMessageKey() string {
return strconv.FormatInt(m.Timestamp, 10)
}
// ExtractMessagesFromStateJSON parses the state JSON and extracts messages
func ExtractMessagesFromStateJSON(stateJson string) ([]*ClineMessage, error) {
// Parse the state JSON to extract clineMessages
var rawState map[string]interface{}
if err := json.Unmarshal([]byte(stateJson), &rawState); err != nil {
return nil, fmt.Errorf("failed to parse state JSON: %w", err)
}
// Try to extract clineMessages
clineMessagesRaw, exists := rawState["clineMessages"]
if !exists {
return []*ClineMessage{}, nil
}
// Convert to JSON and back to get proper Message structs
clineMessagesJson, err := json.Marshal(clineMessagesRaw)
if err != nil {
return nil, fmt.Errorf("failed to marshal clineMessages: %w", err)
}
var messages []*ClineMessage
if err := json.Unmarshal(clineMessagesJson, &messages); err != nil {
return nil, fmt.Errorf("failed to unmarshal clineMessages: %w", err)
}
return messages, nil
}
// ConvertProtoToMessage converts a protobuf ClineMessage to our local Message struct
func ConvertProtoToMessage(protoMsg *cline.ClineMessage) *ClineMessage {
var msgType MessageType
var say, ask string
// Convert message type
switch protoMsg.Type {
case cline.ClineMessageType_ASK:
msgType = MessageTypeAsk
ask = convertProtoAskType(protoMsg.Ask)
case cline.ClineMessageType_SAY:
msgType = MessageTypeSay
say = convertProtoSayType(protoMsg.Say)
default:
msgType = MessageTypeSay
say = "unknown"
}
return &ClineMessage{
Type: msgType,
Text: protoMsg.Text,
Timestamp: protoMsg.Ts,
Reasoning: protoMsg.Reasoning,
Say: say,
Ask: ask,
Partial: protoMsg.Partial,
LastCheckpointHash: protoMsg.LastCheckpointHash,
IsCheckpointCheckedOut: protoMsg.IsCheckpointCheckedOut,
IsOperationOutsideWorkspace: protoMsg.IsOperationOutsideWorkspace,
}
}
// convertProtoAskType converts protobuf ask type to string
func convertProtoAskType(askType cline.ClineAsk) string {
switch askType {
case cline.ClineAsk_FOLLOWUP:
return string(AskTypeFollowup)
case cline.ClineAsk_PLAN_MODE_RESPOND:
return string(AskTypePlanModeRespond)
case cline.ClineAsk_COMMAND:
return string(AskTypeCommand)
case cline.ClineAsk_COMMAND_OUTPUT:
return string(AskTypeCommandOutput)
case cline.ClineAsk_COMPLETION_RESULT:
return string(AskTypeCompletionResult)
case cline.ClineAsk_TOOL:
return string(AskTypeTool)
case cline.ClineAsk_API_REQ_FAILED:
return string(AskTypeAPIReqFailed)
case cline.ClineAsk_RESUME_TASK:
return string(AskTypeResumeTask)
case cline.ClineAsk_RESUME_COMPLETED_TASK:
return string(AskTypeResumeCompletedTask)
case cline.ClineAsk_MISTAKE_LIMIT_REACHED:
return string(AskTypeMistakeLimitReached)
case cline.ClineAsk_AUTO_APPROVAL_MAX_REQ_REACHED:
return string(AskTypeAutoApprovalMaxReached)
case cline.ClineAsk_BROWSER_ACTION_LAUNCH:
return string(AskTypeBrowserActionLaunch)
case cline.ClineAsk_USE_MCP_SERVER:
return string(AskTypeUseMcpServer)
case cline.ClineAsk_NEW_TASK:
return string(AskTypeNewTask)
case cline.ClineAsk_CONDENSE:
return string(AskTypeCondense)
case cline.ClineAsk_REPORT_BUG:
return string(AskTypeReportBug)
default:
return "unknown"
}
}
// convertProtoSayType converts protobuf say type to string
func convertProtoSayType(sayType cline.ClineSay) string {
switch sayType {
case cline.ClineSay_TASK:
return string(SayTypeTask)
case cline.ClineSay_ERROR:
return string(SayTypeError)
case cline.ClineSay_API_REQ_STARTED:
return string(SayTypeAPIReqStarted)
case cline.ClineSay_API_REQ_FINISHED:
return string(SayTypeAPIReqFinished)
case cline.ClineSay_TEXT:
return string(SayTypeText)
case cline.ClineSay_REASONING:
return string(SayTypeReasoning)
case cline.ClineSay_COMPLETION_RESULT_SAY:
return string(SayTypeCompletionResult)
case cline.ClineSay_USER_FEEDBACK:
return string(SayTypeUserFeedback)
case cline.ClineSay_USER_FEEDBACK_DIFF:
return string(SayTypeUserFeedbackDiff)
case cline.ClineSay_API_REQ_RETRIED:
return string(SayTypeAPIReqRetried)
case cline.ClineSay_COMMAND_SAY:
return string(SayTypeCommand)
case cline.ClineSay_COMMAND_OUTPUT_SAY:
return string(SayTypeCommandOutput)
case cline.ClineSay_TOOL_SAY:
return string(SayTypeTool)
case cline.ClineSay_SHELL_INTEGRATION_WARNING:
return string(SayTypeShellIntegrationWarning)
case cline.ClineSay_BROWSER_ACTION_LAUNCH_SAY:
return string(SayTypeBrowserActionLaunch)
case cline.ClineSay_BROWSER_ACTION:
return string(SayTypeBrowserAction)
case cline.ClineSay_BROWSER_ACTION_RESULT:
return string(SayTypeBrowserActionResult)
case cline.ClineSay_MCP_SERVER_REQUEST_STARTED:
return string(SayTypeMcpServerRequestStarted)
case cline.ClineSay_MCP_SERVER_RESPONSE:
return string(SayTypeMcpServerResponse)
case cline.ClineSay_MCP_NOTIFICATION:
return string(SayTypeMcpNotification)
case cline.ClineSay_USE_MCP_SERVER_SAY:
return string(SayTypeUseMcpServer)
case cline.ClineSay_DIFF_ERROR:
return string(SayTypeDiffError)
case cline.ClineSay_DELETED_API_REQS:
return string(SayTypeDeletedAPIReqs)
case cline.ClineSay_CLINEIGNORE_ERROR:
return string(SayTypeClineignoreError)
case cline.ClineSay_CHECKPOINT_CREATED:
return string(SayTypeCheckpointCreated)
case cline.ClineSay_LOAD_MCP_DOCUMENTATION:
return string(SayTypeLoadMcpDocumentation)
case cline.ClineSay_INFO:
return string(SayTypeInfo)
case cline.ClineSay_TASK_PROGRESS:
return string(SayTypeTaskProgress)
default:
return "unknown"
}
}
-61
View File
@@ -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"`
}
-47
View File
@@ -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
}

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