mirror of
https://github.com/cline/cline.git
synced 2026-09-01 15:11:04 +08:00
Compare commits
414 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c99fc7ebe | |||
| 9c1a16e372 | |||
| 219bc0621a | |||
| 6c8f81e34f | |||
| 0ea3dcd09a | |||
| 08d15da1a1 | |||
| 683d88fba0 | |||
| 9409cf9b19 | |||
| 79df99de35 | |||
| 72dd95e74e | |||
| 257b78e0bc | |||
| 2a4a4c0d46 | |||
| 3fedf08c7f | |||
| 93334679d8 | |||
| 2d9b7e5e07 | |||
| 46f0b77d43 | |||
| 94e4e320ca | |||
| f5961d8529 | |||
| 5eaece8413 | |||
| b391ded91d | |||
| 7fbcf4fa31 | |||
| a8df52b34e | |||
| 118a80ea6c | |||
| d97ef02718 | |||
| bdb1638cbd | |||
| 71d585ab2b | |||
| 4b21ca939c | |||
| c5410701df | |||
| 6a1ac78736 | |||
| 349158b3d2 | |||
| c4aa50dc45 | |||
| 8f64cf4a34 | |||
| 84c02e215e | |||
| d57cbaf63d | |||
| 1a54e375a3 | |||
| 2eacade09e | |||
| 798fc87a96 | |||
| c34112b2b7 | |||
| a108ca2154 | |||
| 061039c811 | |||
| efc2c70a1a | |||
| c9dd4ae226 | |||
| d0ebe85535 | |||
| d8de30c330 | |||
| 9cd424523c | |||
| 59251e9de6 | |||
| 2df952f571 | |||
| 6e7dc55781 | |||
| ec879fc549 | |||
| 75b050de5c | |||
| 0c4acd9457 | |||
| 0dc3d7084d | |||
| 3cfafe6583 | |||
| d8aefbaabd | |||
| 1ff60522f1 | |||
| 42321e5b95 | |||
| b2634d2276 | |||
| 3755817060 | |||
| 8ae18e6a16 | |||
| 113d7fb292 | |||
| 592d045565 | |||
| 8ea54704a6 | |||
| 1734c669a0 | |||
| fb695602a4 | |||
| 5d7f0f04d3 | |||
| 13cf28d8f4 | |||
| 6238fab366 | |||
| 7c26a7d16b | |||
| 7885c75a4f | |||
| 8994d125be | |||
| 79d88f7708 | |||
| 4d092bfba6 | |||
| dc5c6f916b | |||
| 50332ec46a | |||
| 5ccd062839 | |||
| ae9eceb113 | |||
| dd35448a9d | |||
| 32aa16612d | |||
| bffe5c4d2a | |||
| b18d7012aa | |||
| e0965821fc | |||
| 9e46e9fd22 | |||
| 890a1f7ac8 | |||
| b89a73c193 | |||
| 51927dba33 | |||
| 7634f22104 | |||
| fbf784f78b | |||
| 62bf50a659 | |||
| d850fbc0ad | |||
| 4dd6c6dcc7 | |||
| 8813f8252c | |||
| 3210c4bc4b | |||
| 9f3daa4151 | |||
| ac2db41815 | |||
| df1d33c751 | |||
| 361494d18f | |||
| dca0a8fa3e | |||
| 6d7213dc6a | |||
| 97a35d3868 | |||
| 4ec9155c46 | |||
| bffca989a1 | |||
| 0133b5d030 | |||
| a94c4be438 | |||
| d70792e539 | |||
| e4ddaac627 | |||
| 9478b600aa | |||
| d194e47bf6 | |||
| c9ff9cf1d5 | |||
| d7fa6b33c1 | |||
| 8f521e7ea3 | |||
| 3cb8d0fbcf | |||
| 5f2bf6329f | |||
| 963abc190e | |||
| 631a7d6566 | |||
| e43ab0ea7a | |||
| 242e3321a2 | |||
| ea6cb4b29e | |||
| 2c75285566 | |||
| 8279f2e145 | |||
| 5b94ba3ef9 | |||
| 703146182a | |||
| 9603643b77 | |||
| c6dce7fb17 | |||
| cc0d4ae6cb | |||
| a9365e30e9 | |||
| efe468d9b1 | |||
| 1b6202604d | |||
| ea1dbd8bea | |||
| e43517519e | |||
| 520d08c5f2 | |||
| 31a55ac87e | |||
| a442983742 | |||
| 7b71eff294 | |||
| 6d1890f8bb | |||
| 11d17fc17e | |||
| 42a3dc6150 | |||
| 4032e51e8d | |||
| 1bbc90487c | |||
| d422ebbb27 | |||
| 7470d234ef | |||
| 21b81f1844 | |||
| 362429a317 | |||
| 09cb9ac9ac | |||
| 94160faeef | |||
| f526f70e3a | |||
| d9b47378c6 | |||
| 0671c59e6d | |||
| bf87887501 | |||
| 66a81a6efa | |||
| eee64c5204 | |||
| 748ba99c1c | |||
| 82b1a01644 | |||
| 1c6307e8ad | |||
| 58d9c0af18 | |||
| bf213c24ea | |||
| 80cceaa3ae | |||
| 359e088eb6 | |||
| 47a464defe | |||
| 38f619cfd9 | |||
| 050773ac31 | |||
| 6d67ff0b94 | |||
| 18f4ef8b49 | |||
| 085e69d142 | |||
| 46aa66ed9d | |||
| 2ebbe954d9 | |||
| 067f5eea09 | |||
| 2a48bad28c | |||
| c6f4584f7d | |||
| a17b31070f | |||
| cad82d518d | |||
| b4d7ec187f | |||
| 42af8414e4 | |||
| 8f6b9e8362 | |||
| f1430359db | |||
| 932695f70b | |||
| 489ee936c2 | |||
| dff7f61175 | |||
| aead42c6b8 | |||
| db50a1c671 | |||
| bb20f60f1d | |||
| a7333b7177 | |||
| f30837a850 | |||
| 5660b2513f | |||
| 64e7e5fa4c | |||
| b34166e99a | |||
| cd2d8f98a7 | |||
| 333468c9b6 | |||
| 0da6ddc001 | |||
| 612130366f | |||
| 85206cfded | |||
| 6f8ed7aa56 | |||
| f2c130a69e | |||
| c870bfb454 | |||
| 436bfdb535 | |||
| e2a3652d26 | |||
| 632aca225c | |||
| a1eaccdaef | |||
| f7e76f9bee | |||
| 0d04205dc4 | |||
| 4b9dbf11a0 | |||
| c5afbd743e | |||
| 6d1bfc3a1b | |||
| d18486a199 | |||
| 7417b1b671 | |||
| e4fbe331f4 | |||
| d122683e91 | |||
| fdc96d0545 | |||
| c0241f4060 | |||
| c3f523427b | |||
| 428e465d4d | |||
| 46ab08c9f8 | |||
| 0fd30e0d20 | |||
| e87a765773 | |||
| e80c3d3cd8 | |||
| 80b6ab6046 | |||
| 5155123b80 | |||
| 30af711d5e | |||
| b218b25e0b | |||
| bf27aa53ef | |||
| 4644a7159a | |||
| 63c63f38f5 | |||
| f1d8ca423d | |||
| 16b8ce7e44 | |||
| 0bbb4299ba | |||
| 197b17b3bd | |||
| 591b840531 | |||
| c35ba37402 | |||
| 850a6ab841 | |||
| 99c85710e9 | |||
| 9fc9785aa2 | |||
| 5c02f6eb13 | |||
| ac01e69555 | |||
| dbc18a8fce | |||
| b77398390b | |||
| c20052de00 | |||
| 1dac507b63 | |||
| e5aa48c2b5 | |||
| 63efb4aa5d | |||
| 5fcf83b627 | |||
| 0b308d610e | |||
| 60d3048aa0 | |||
| aed3ac6597 | |||
| 86e2a3e7ce | |||
| 47856c70d2 | |||
| f1a84ddbde | |||
| ebcc927cc7 | |||
| 1e108d87e0 | |||
| 7b62d7786e | |||
| 042f5c9823 | |||
| 2f8a4525a4 | |||
| 20774a4187 | |||
| be5bda2740 | |||
| 261fd9036f | |||
| 2334d4d531 | |||
| 6390d854f7 | |||
| 7d5c56a55a | |||
| 0dc760ac03 | |||
| 8c1241c8cd | |||
| 17686ae3d9 | |||
| 97460d2952 | |||
| e629ed0ef6 | |||
| 96788e9127 | |||
| d7716a514d | |||
| 7aaa5966d6 | |||
| bb1d068139 | |||
| 450945ae0e | |||
| 191e9635bd | |||
| c13a7a80b3 | |||
| d4a4adfa5f | |||
| 557e20224e | |||
| 0e9a326a6a | |||
| f5ecb6db0c | |||
| 8f1405b881 | |||
| 8a9e03c8ff | |||
| edba02b45e | |||
| cc36c67fc9 | |||
| d77032bc8a | |||
| 608dde94b3 | |||
| 47ff7c1620 | |||
| 1b7f971c34 | |||
| 12eadd3378 | |||
| b3e0ef9ed7 | |||
| fb94d8d3d4 | |||
| d11bd15d60 | |||
| 31c48898a6 | |||
| 5c9901d68a | |||
| 45b79dc3d7 | |||
| 26b6c7bdb6 | |||
| d0678a2ad1 | |||
| 09276ebf43 | |||
| af8b51b189 | |||
| 3e6b3f252b | |||
| f019c365a6 | |||
| f6fe843cfb | |||
| 01f26c21ae | |||
| 031c2f5b05 | |||
| c999e269db | |||
| 032c1bf792 | |||
| fa73d60b7a | |||
| 841bb7f1d7 | |||
| f01428884d | |||
| 2ce5548250 | |||
| 9f32cd247f | |||
| ace48198cc | |||
| 9f0240dfd7 | |||
| 5530cfe375 | |||
| af4d99e0bc | |||
| cd011a0e4a | |||
| d44184ab03 | |||
| 5d96704e92 | |||
| 49812eb332 | |||
| 57b72519ae | |||
| 1ed4d00a16 | |||
| c090f5b1a7 | |||
| 3c917ec99d | |||
| b315be397d | |||
| dc9e7916de | |||
| cb9d1e81b8 | |||
| eb5a452c9c | |||
| 6a90294a2a | |||
| 2e1334c10a | |||
| 18b77ee5e5 | |||
| 4bf8feecac | |||
| 2f3450f667 | |||
| 3e89c28727 | |||
| d065ac7b37 | |||
| 5a0a25601a | |||
| 48d822a030 | |||
| 417104505c | |||
| e21d3ff1bb | |||
| da5477f891 | |||
| 4903dfcb6e | |||
| 37b2f7fbc9 | |||
| 11fbe4b21d | |||
| e0844ac6e2 | |||
| f28d760675 | |||
| 8363d090e0 | |||
| 3409fa7442 | |||
| a20685d289 | |||
| 926c5e189e | |||
| 2a5ca9d312 | |||
| d06717342b | |||
| c904cfe376 | |||
| 924ca1278c | |||
| 2b0c0a659d | |||
| f4477e229d | |||
| 5207d5c68e | |||
| 51b535e2d6 | |||
| 4db61b1b36 | |||
| 6baf611322 | |||
| 97d635d606 | |||
| 8ce476ccaa | |||
| bce403476e | |||
| ac306c3719 | |||
| 1d8a9bf11e | |||
| d811a9d5f3 | |||
| 3abeff0379 | |||
| 2763266424 | |||
| 0c3dfb6aed | |||
| 0e73cd4c9f | |||
| 6bbe5c2499 | |||
| 00e9d6f523 | |||
| 010fd71140 | |||
| 17a95f0021 | |||
| 27c9971774 | |||
| 644d06c487 | |||
| 1be314dfed | |||
| 91940fbb4a | |||
| 279e371cf5 | |||
| 4eda267981 | |||
| 3441363805 | |||
| 5389be991e | |||
| 41fd610203 | |||
| e1e7470fdd | |||
| 2f0e0ee56d | |||
| 7f89ebf276 | |||
| 99ed1b7e86 | |||
| 45c9fcf575 | |||
| 4f73f4460b | |||
| fb872fd316 | |||
| 5292242a8e | |||
| 78b8aed50f | |||
| d01f7b4618 | |||
| dec215cd9c | |||
| 8ce7e132d2 | |||
| 6ab008b204 | |||
| 769523998d | |||
| 64a6bcc39b | |||
| 19ceafd4a4 | |||
| 4b5218b3b8 | |||
| b68d716675 | |||
| f820c6d00a | |||
| 1fcbbaa9d0 | |||
| 4005df6eae | |||
| 0e3cdab82b | |||
| ada6b0c955 | |||
| 921dd2ec8c | |||
| 4b212994a8 | |||
| 3fe3c866ea | |||
| e9136f60cc | |||
| 34bb95e04e | |||
| 7b65db55f1 | |||
| 07ebc2e4bd | |||
| 0193179597 | |||
| 20c0783c97 | |||
| 4df486fa5b | |||
| 5fc6d4e9e3 | |||
| 00d3bd8316 | |||
| 81d4ff947f | |||
| bcb368d097 | |||
| c1ded819bb | |||
| de02befb2f | |||
| 6617901078 | |||
| 31c7176de1 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Add claude 4.5 haiku
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Add Codex models to OpenAI provider with Responses API support. Native tool calling must be enabled.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: finalize document content during approval flow
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Expose --version in cline cli command
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Log Persistence errors to PostHog
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
feat: add OpenAI Codex (ChatGPT Plus/Pro) provider
|
||||
|
||||
Users with ChatGPT Plus or Pro subscriptions can now use GPT-5 models directly through Cline without needing an API key. Authentication is handled via OAuth through OpenAI's authentication system.
|
||||
|
||||
Models available:
|
||||
- gpt-5.2-codex (default)
|
||||
- gpt-5.1-codex-max
|
||||
- gpt-5.1-codex-mini
|
||||
- gpt-5.2
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix two bugs in DiffViewProvider file editing:
|
||||
|
||||
1. **Line boundary validation**: Add `safelyTruncateDocument()` to prevent out-of-bounds line errors on JetBrains hosts (fixes #8423, #8429). The gRPC protocol strictly validates line numbers, causing "truncateDocument INTERNAL: Wrong line" errors when `truncateDocument()` was called with a line number >= document line count.
|
||||
|
||||
2. **Content concatenation on final update**: When replacing content without a trailing newline, the old content at line N+1 was concatenated to the new content. Fixed by extending the replacement range to cover the entire document on final update.
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
docs: fix outdated Ollama model names in documentation
|
||||
|
||||
Updated recommended Ollama models to use correct identifiers:
|
||||
- Changed qwen3-coder-30b to qwen2.5-coder:32b
|
||||
- Changed devstral-small to codellama:34b-code
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
OpenAI GPT-5 Codex models are now using Apply Patch tool for diff edits.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Add DeepSeek 3.2 to native tool calling allow list
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix issue where tool call ids are invalid when switching between models using the chat completion format and the responses api format.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: add chat output on skill use
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Adding telemetry for background exec terminal
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
This pull request introduces comprehensive Jupyter Notebook support for Cline, enabling AI-assisted editing of `.ipynb` files with full cell-level context awareness. The feature allows users to seamlessly work with Jupyter notebooks using Cline's AI capabilities while preserving the notebook's JSON structure.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Prevent simultaneuos refreshes when restoring auth info
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: removes retry message from UI after retry succeeds
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Support native tool calling for LM Studio and Ollama provider
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
add claude 4.5 opus into sap provider.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Limite Vertex and LiteLLM options when they're remote configured
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix crash when the Context Menu has a type but no options
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix(ollama): abort streaming requests when task is cancelled
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Throttle the remote config fetch
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Improve history view filter menu
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"cline": patch
|
||||
---
|
||||
|
||||
Add git worktree management UI for running parallel Cline sessions
|
||||
+1
@@ -0,0 +1 @@
|
||||
../../.clinerules/workflows/hotfix-release.md
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.clinerules/workflows/release.md
|
||||
Executable
+51
@@ -0,0 +1,51 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Only run in Claude Code remote environments
|
||||
if [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cd "$CLAUDE_PROJECT_DIR"
|
||||
|
||||
echo "=== Claude Code for Web Setup ==="
|
||||
echo ""
|
||||
|
||||
# Install latest gh CLI tool
|
||||
echo "Installing GitHub CLI..."
|
||||
GH_VERSION=$(curl -s https://api.github.com/repos/cli/cli/releases/latest | grep '"tag_name"' | cut -d'"' -f4 | sed 's/^v//')
|
||||
curl -sL "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_amd64.tar.gz" -o /tmp/gh.tar.gz
|
||||
tar -xzf /tmp/gh.tar.gz -C /tmp
|
||||
sudo mv "/tmp/gh_${GH_VERSION}_linux_amd64/bin/gh" /usr/local/bin/gh
|
||||
rm -rf /tmp/gh.tar.gz /tmp/gh_${GH_VERSION}_linux_amd64
|
||||
echo "Installed gh version: $(gh --version | head -1)"
|
||||
echo ""
|
||||
|
||||
# Check if GITHUB_TOKEN is set and configure gh
|
||||
if [ -n "${GITHUB_TOKEN:-}" ]; then
|
||||
echo "GITHUB_TOKEN is configured - gh CLI is ready to use"
|
||||
echo ""
|
||||
echo "You can use gh commands directly, for example:"
|
||||
echo " gh issue list --repo cline/cline --limit 5"
|
||||
echo " gh pr list --repo cline/cline --state open"
|
||||
echo " gh issue view 123 --repo cline/cline"
|
||||
echo ""
|
||||
else
|
||||
echo "GITHUB_TOKEN is not set - gh CLI will have limited functionality"
|
||||
echo ""
|
||||
echo "To enable full GitHub API access:"
|
||||
echo "1. Create a Fine-grained Personal Access Token at https://github.com/settings/tokens?type=beta"
|
||||
echo "2. Add it as GITHUB_TOKEN in your Claude Code environment settings"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Install project dependencies
|
||||
echo "Installing dependencies..."
|
||||
npm run install:all
|
||||
|
||||
# Generate gRPC/protobuf types (required for TypeScript)
|
||||
echo "Generating proto types..."
|
||||
npm run protos
|
||||
|
||||
echo ""
|
||||
echo "Session setup complete!"
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/claude-code-for-web-setup.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
---
|
||||
name: create-pull-request
|
||||
description: Create a GitHub pull request following project conventions. Use when the user asks to create a PR, submit changes for review, or open a pull request. Handles commit analysis, branch management, and PR creation using the gh CLI tool.
|
||||
---
|
||||
|
||||
# Create Pull Request
|
||||
|
||||
This skill guides you through creating a well-structured GitHub pull request that follows project conventions and best practices.
|
||||
|
||||
## Prerequisites Check
|
||||
|
||||
Before proceeding, verify the following:
|
||||
|
||||
### 1. Check if `gh` CLI is installed
|
||||
|
||||
```bash
|
||||
gh --version
|
||||
```
|
||||
|
||||
If not installed, inform the user:
|
||||
> The GitHub CLI (`gh`) is required but not installed. Please install it:
|
||||
> - macOS: `brew install gh`
|
||||
> - Other: https://cli.github.com/
|
||||
|
||||
### 2. Check if authenticated with GitHub
|
||||
|
||||
```bash
|
||||
gh auth status
|
||||
```
|
||||
|
||||
If not authenticated, guide the user to run `gh auth login`.
|
||||
|
||||
### 3. Verify clean working directory
|
||||
|
||||
```bash
|
||||
git status
|
||||
```
|
||||
|
||||
If there are uncommitted changes, ask the user whether to:
|
||||
- Commit them as part of this PR
|
||||
- Stash them temporarily
|
||||
- Discard them (with caution)
|
||||
|
||||
## Gather Context
|
||||
|
||||
### 1. Identify the current branch
|
||||
|
||||
```bash
|
||||
git branch --show-current
|
||||
```
|
||||
|
||||
Ensure you're not on `main` or `master`. If so, ask the user to create or switch to a feature branch.
|
||||
|
||||
### 2. Find the base branch
|
||||
|
||||
```bash
|
||||
git remote show origin | grep "HEAD branch"
|
||||
```
|
||||
|
||||
This is typically `main` or `master`.
|
||||
|
||||
### 3. Analyze recent commits relevant to this PR
|
||||
|
||||
```bash
|
||||
git log origin/main..HEAD --oneline --no-decorate
|
||||
```
|
||||
|
||||
Review these commits to understand:
|
||||
- What changes are being introduced
|
||||
- The scope of the PR (single feature/fix or multiple changes)
|
||||
- Whether commits should be squashed or reorganized
|
||||
|
||||
### 4. Review the diff
|
||||
|
||||
```bash
|
||||
git diff origin/main..HEAD --stat
|
||||
```
|
||||
|
||||
This shows which files changed and helps identify the type of change.
|
||||
|
||||
## Information Gathering
|
||||
|
||||
Before creating the PR, you need the following information. Check if it can be inferred from:
|
||||
- Commit messages
|
||||
- Branch name (e.g., `fix/issue-123`, `feature/new-login`)
|
||||
- Changed files and their content
|
||||
|
||||
If any critical information is missing, use `ask_followup_question` to ask the user:
|
||||
|
||||
### Required Information
|
||||
|
||||
1. **Related Issue Number**: Look for patterns like `#123`, `fixes #123`, or `closes #123` in commit messages
|
||||
2. **Description**: What problem does this solve? Why were these changes made?
|
||||
3. **Type of Change**: Bug fix, new feature, breaking change, refactor, cosmetic, documentation, or workflow
|
||||
4. **Test Procedure**: How was this tested? What could break?
|
||||
|
||||
### Example clarifying question
|
||||
|
||||
If the issue number is not found:
|
||||
> I couldn't find a related issue number in the commit messages or branch name. What GitHub issue does this PR address? (Enter the issue number, e.g., "123" or "N/A" for small fixes)
|
||||
|
||||
## Git Best Practices
|
||||
|
||||
Before creating the PR, consider these best practices:
|
||||
|
||||
### Commit Hygiene
|
||||
|
||||
1. **Atomic commits**: Each commit should represent a single logical change
|
||||
2. **Clear commit messages**: Follow conventional commit format when possible
|
||||
3. **No merge commits**: Prefer rebasing over merging to keep history clean
|
||||
|
||||
### Branch Management
|
||||
|
||||
1. **Rebase on latest main** (if needed):
|
||||
```bash
|
||||
git fetch origin
|
||||
git rebase origin/main
|
||||
```
|
||||
|
||||
2. **Squash if appropriate**: If there are many small "WIP" commits, consider interactive rebase:
|
||||
```bash
|
||||
git rebase -i origin/main
|
||||
```
|
||||
Only suggest this if commits appear messy and the user is comfortable with rebasing.
|
||||
|
||||
### Push Changes
|
||||
|
||||
Ensure all commits are pushed:
|
||||
```bash
|
||||
git push origin HEAD
|
||||
```
|
||||
|
||||
If the branch was rebased, you may need:
|
||||
```bash
|
||||
git push origin HEAD --force-with-lease
|
||||
```
|
||||
|
||||
## Create the Pull Request
|
||||
|
||||
**IMPORTANT**: Read and use the PR template at `.github/pull_request_template.md`. The PR body format must **strictly match** the template structure. Do not deviate from the template format.
|
||||
|
||||
When filling out the template:
|
||||
- Replace `#XXXX` with the actual issue number, or keep as `#XXXX` if no issue exists (for small fixes)
|
||||
- Fill in all sections with relevant information gathered from commits and context
|
||||
- Mark the appropriate "Type of Change" checkbox(es)
|
||||
- Complete the "Pre-flight Checklist" items that apply
|
||||
|
||||
### Create PR with gh CLI
|
||||
|
||||
```bash
|
||||
gh pr create --title "PR_TITLE" --body "PR_BODY" --base main
|
||||
```
|
||||
|
||||
Alternatively, create as draft if the user wants review before marking ready:
|
||||
```bash
|
||||
gh pr create --title "PR_TITLE" --body "PR_BODY" --base main --draft
|
||||
```
|
||||
|
||||
## Post-Creation
|
||||
|
||||
After creating the PR:
|
||||
|
||||
1. **Display the PR URL** so the user can review it
|
||||
2. **Remind about CI checks**: Tests and linting will run automatically
|
||||
3. **Suggest next steps**:
|
||||
- Add reviewers if needed: `gh pr edit --add-reviewer USERNAME`
|
||||
- Add labels if needed: `gh pr edit --add-label "bug"`
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **No commits ahead of main**: The branch has no changes to submit
|
||||
- Ask if the user meant to work on a different branch
|
||||
|
||||
2. **Branch not pushed**: Remote doesn't have the branch
|
||||
- Push the branch first: `git push -u origin HEAD`
|
||||
|
||||
3. **PR already exists**: A PR for this branch already exists
|
||||
- Show the existing PR: `gh pr view`
|
||||
- Ask if they want to update it instead
|
||||
|
||||
4. **Merge conflicts**: Branch conflicts with base
|
||||
- Guide user through resolving conflicts or rebasing
|
||||
|
||||
## Summary Checklist
|
||||
|
||||
Before finalizing, ensure:
|
||||
- [ ] `gh` CLI is installed and authenticated
|
||||
- [ ] Working directory is clean
|
||||
- [ ] All commits are pushed
|
||||
- [ ] Branch is up-to-date with base branch
|
||||
- [ ] Related issue number is identified, or placeholder is used
|
||||
- [ ] PR description follows the template exactly
|
||||
- [ ] Appropriate type of change is selected
|
||||
- [ ] Pre-flight checklist items are addressed
|
||||
@@ -0,0 +1,194 @@
|
||||
This file is the secret sauce for working effectively in this codebase. It captures tribal knowledge—the nuanced, non-obvious patterns that make the difference between a quick fix and hours of back-and-forth & human intervention.
|
||||
|
||||
**When to add to this file:**
|
||||
- User had to intervene, correct, or hand-hold
|
||||
- Multiple back-and-forth attempts were needed to get something working
|
||||
- You discovered something that required reading many files to understand
|
||||
- A change touched files you wouldn't have guessed
|
||||
- Something worked differently than you expected
|
||||
- User explicitly asks to "add this to CLAUDE.md"
|
||||
|
||||
**Proactively suggest additions** when any of the above happen—don't wait to be asked.
|
||||
|
||||
**What NOT to add:** Stuff you can figure out from reading a few files, obvious patterns, or standard practices. This file should be high-signal, not comprehensive.
|
||||
|
||||
## Miscellaneous
|
||||
- This is a VS Code extension—check `package.json` for available scripts before trying to verify builds (e.g., `npm run compile`, not `npm run build`).
|
||||
- When creating PRs, if the change is user-facing and significant enough to warrant a changelog entry, run `npm run changeset` and create a patch changeset. Never create minor or major version bumps. Skip changesets for trivial fixes, internal refactors, or minor UI tweaks that users wouldn't notice.
|
||||
- When adding new feature flags, see this PR as a reference https://github.com/cline/cline/pull/7566
|
||||
- Additional instructions about making requests: @.clinerules/network.md
|
||||
|
||||
## gRPC/Protobuf Communication
|
||||
The extension and webview communicate via gRPC-like protocol over VS Code message passing.
|
||||
|
||||
**Proto files live in `proto/`** (e.g., `proto/cline/task.proto`, `proto/cline/ui.proto`)
|
||||
- Each feature domain has its own `.proto` file
|
||||
- For simple data, use shared types in `proto/cline/common.proto` (`StringRequest`, `Empty`, `Int64Request`)
|
||||
- For complex data, define custom messages in the feature's `.proto` file
|
||||
- Naming: Services `PascalCaseService`, RPCs `camelCase`, Messages `PascalCase`
|
||||
- For streaming responses, use `stream` keyword (see `subscribeToAuthCallback` in `account.proto`)
|
||||
|
||||
**Run `npm run protos`** after any proto changes—generates types in:
|
||||
- `src/shared/proto/` - Shared type definitions
|
||||
- `src/generated/grpc-js/` - Service implementations
|
||||
- `src/generated/nice-grpc/` - Promise-based clients
|
||||
- `src/generated/hosts/` - Generated handlers
|
||||
|
||||
**Adding new enum values** (like a new `ClineSay` type) requires updating conversion mappings in `src/shared/proto-conversions/cline-message.ts`
|
||||
|
||||
**Adding new RPC methods** requires:
|
||||
- Handler in `src/core/controller/<domain>/`
|
||||
- Call from webview via generated client: `UiServiceClient.scrollToSettings(StringRequest.create({ value: "browser" }))`
|
||||
|
||||
**Example—the `explain-changes` feature touched:**
|
||||
- `proto/cline/task.proto` - Added `ExplainChangesRequest` message and `explainChanges` RPC
|
||||
- `proto/cline/ui.proto` - Added `GENERATE_EXPLANATION = 29` to `ClineSay` enum
|
||||
- `src/shared/ExtensionMessage.ts` - Added `ClineSayGenerateExplanation` type
|
||||
- `src/shared/proto-conversions/cline-message.ts` - Added mapping for new say type
|
||||
- `src/core/controller/task/explainChanges.ts` - Handler implementation
|
||||
- `webview-ui/src/components/chat/ChatRow.tsx` - UI rendering
|
||||
|
||||
## Adding a New API Provider
|
||||
When adding a new provider (e.g., "openai-codex"), you must update the proto conversion layer in THREE places or the provider will silently reset to Anthropic:
|
||||
|
||||
1. `proto/cline/models.proto` - Add to the `ApiProvider` enum (e.g., `OPENAI_CODEX = 40;`)
|
||||
2. `convertApiProviderToProto()` in `src/shared/proto-conversions/models/api-configuration-conversion.ts` - Add case mapping string to proto enum
|
||||
3. `convertProtoToApiProvider()` in the same file - Add case mapping proto enum back to string
|
||||
|
||||
**Why this matters:** Without these, the provider string hits the `default` case and returns `ANTHROPIC`. The webview, provider list, and handler all work fine, but the state silently resets when it round-trips through proto serialization. No error is thrown.
|
||||
|
||||
**Other files to update when adding a provider:**
|
||||
- `src/shared/api.ts` - Add to `ApiProvider` union type, define models
|
||||
- `src/shared/providers/providers.json` - Add to provider list for dropdown
|
||||
- `src/core/api/index.ts` - Register handler in `createHandlerForProvider()`
|
||||
- `webview-ui/src/components/settings/utils/providerUtils.ts` - Add cases in `getModelsForProvider()` and `normalizeApiConfiguration()`
|
||||
- `webview-ui/src/utils/validate.ts` - Add validation case
|
||||
- `webview-ui/src/components/settings/ApiOptions.tsx` - Render provider component
|
||||
|
||||
## Responses API Providers (OpenAI Codex, OpenAI Native)
|
||||
Providers using OpenAI's Responses API require native tool calling. XML tools don't work with the Responses API.
|
||||
|
||||
**Symptoms of broken native tool calling:**
|
||||
- Tools get called multiple times (e.g., `ask_followup_question` asks the same question twice)
|
||||
- Tool arguments get duplicated or malformed
|
||||
- The model responds but tools aren't recognized
|
||||
|
||||
**Root causes to check:**
|
||||
1. **Provider missing from `isNextGenModelProvider()`** in `src/utils/model-utils.ts`. The native variant matchers (e.g., `native-gpt-5/config.ts`) call this function. If your provider isn't in the list, the matcher returns false and falls back to XML tools.
|
||||
|
||||
2. **Model missing `apiFormat: ApiFormat.OPENAI_RESPONSES`** in its model info (`src/shared/api.ts`). This property signals that the model requires native tool calling. The task runner in `src/core/task/index.ts` checks this and forces `enableNativeToolCalls: true` regardless of user settings.
|
||||
|
||||
**When adding a new Responses API provider:**
|
||||
1. Add provider to `isNextGenModelProvider()` list in `src/utils/model-utils.ts`
|
||||
2. Set `apiFormat: ApiFormat.OPENAI_RESPONSES` on all models that use the Responses API
|
||||
3. The variant matcher and task runner will handle the rest automatically
|
||||
|
||||
## Adding Tools to System Prompt
|
||||
This is tricky—multiple prompt variants and configs. **Always search for existing similar tools first and follow their pattern.** Look at the full chain from prompt definition → variant configs → handler → UI before implementing.
|
||||
|
||||
1. **Add to `ClineDefaultTool` enum** in `src/shared/tools.ts`
|
||||
2. **Tool definition** in `src/core/prompts/system-prompt/tools/` (create file like `generate_explanation.ts`)
|
||||
- Define variants for each `ModelFamily` (generic, next-gen, xs, etc.)
|
||||
- Export variants array (e.g., `export const my_tool_variants = [GENERIC, NATIVE_NEXT_GEN, XS]`)
|
||||
- **Fallback behavior**: If a variant isn't defined for a model family, `ClineToolSet.getToolByNameWithFallback()` automatically falls back to GENERIC. So you only need to export `[GENERIC]` unless the tool needs model-specific behavior.
|
||||
3. **Register in `src/core/prompts/system-prompt/tools/init.ts`** - Import and spread into `allToolVariants`
|
||||
4. **Add to variant configs** - Each model family has its own config in `src/core/prompts/system-prompt/variants/*/config.ts`. Add your tool's enum to the `.tools()` list:
|
||||
- `generic/config.ts`, `next-gen/config.ts`, `gpt-5/config.ts`, `native-gpt-5/config.ts`, `native-gpt-5-1/config.ts`, `native-next-gen/config.ts`, `gemini-3/config.ts`, `glm/config.ts`, `hermes/config.ts`, `xs/config.ts`
|
||||
- **Important**: If you add to a variant's config, make sure the tool spec exports a variant for that ModelFamily (or relies on GENERIC fallback)
|
||||
5. **Create handler** in `src/core/task/tools/handlers/`
|
||||
6. **Wire up in `ToolExecutor.ts`** if needed for execution flow
|
||||
7. **Add to tool parsing** in `src/core/assistant-message/index.ts` if needed
|
||||
8. **If tool has UI feedback**: add `ClineSay` enum in proto, update `src/shared/ExtensionMessage.ts`, update `src/shared/proto-conversions/cline-message.ts`, update `webview-ui/src/components/chat/ChatRow.tsx`
|
||||
|
||||
## Modifying System Prompt
|
||||
**Read these first:** `src/core/prompts/system-prompt/README.md`, `tools/README.md`, `__tests__/README.md`
|
||||
|
||||
System prompt is modular: **components** (reusable sections) + **variants** (model-specific configs) + **templates** (with `{{PLACEHOLDER}}` resolution).
|
||||
|
||||
**Key directories:**
|
||||
- `components/` - Shared sections: `rules.ts`, `capabilities.ts`, `editing_files.ts`, etc.
|
||||
- `variants/` - Model-specific: `generic/`, `next-gen/`, `xs/`, `gpt-5/`, `gemini-3/`, `hermes/`, `glm/`, etc.
|
||||
- `templates/` - Template engine and placeholder definitions
|
||||
|
||||
**Variant tiers (ask user which to modify):**
|
||||
- **Next-gen** (Claude 4, GPT-5, Gemini 2.5): `next-gen/`, `native-next-gen/`, `native-gpt-5/`, `native-gpt-5-1/`, `gemini-3/`, `gpt-5/`
|
||||
- **Standard** (default fallback): `generic/`
|
||||
- **Local/small models**: `xs/`, `hermes/`, `glm/`
|
||||
|
||||
**How overrides work:** Variants can override components via `componentOverrides` in their `config.ts`, or provide a custom template in `template.ts` (e.g., `next-gen/template.ts` exports `rules_template`). If no override, the shared component from `components/` is used.
|
||||
|
||||
**Example: Adding a rule to RULES section**
|
||||
1. Check if variant overrides rules: look for `rules_template` in `variants/*/template.ts` or `componentOverrides.RULES` in `config.ts`
|
||||
2. If shared: modify `components/rules.ts`
|
||||
3. If overridden: modify that variant's template
|
||||
4. XS variant is special—has heavily condensed inline content in `template.ts`
|
||||
|
||||
**After any changes, regenerate snapshots:**
|
||||
```bash
|
||||
UPDATE_SNAPSHOTS=true npm run test:unit
|
||||
```
|
||||
Snapshots live in `__tests__/__snapshots__/`. Tests validate across model families and context variations (browser, MCP, focus chain).
|
||||
|
||||
## Modifying Default Slash Commands
|
||||
Three places need updates:
|
||||
- `src/core/slash-commands/index.ts` - Command definitions
|
||||
- `src/core/prompts/commands.ts` - System prompt integration
|
||||
- `webview-ui/src/utils/slash-commands.ts` - Webview autocomplete
|
||||
|
||||
## Adding New Global State Keys
|
||||
Adding a new key to global state requires updates in multiple places. Missing any step causes silent failures.
|
||||
|
||||
Required steps:
|
||||
1. Type definition in `src/shared/storage/state-keys.ts` - Add to `GlobalState` or `Settings` interface
|
||||
2. Read from globalState in `src/core/storage/utils/state-helpers.ts`:
|
||||
- Add `const myKey = context.globalState.get<GlobalStateAndSettings["myKey"]>("myKey")` in `readGlobalStateFromDisk()`
|
||||
- Add to the return object: `myKey: myKey ?? defaultValue,`
|
||||
3. StateManager handles read/write via `setGlobalState()`/`getGlobalStateKey()` after initialization
|
||||
|
||||
Common mistake: Adding only the return value without the `context.globalState.get()` call. This compiles but the value is always `undefined` on load.
|
||||
|
||||
## StateManager Cache vs Direct globalState Access
|
||||
StateManager uses an in-memory cache populated during `StateManager.initialize(context)` in `common.ts`. For most state, use `controller.stateManager.setGlobalState()`/`getGlobalStateKey()`.
|
||||
|
||||
Exception: State needed immediately at extension startup (before cache is ready)
|
||||
|
||||
When Window A sets state and immediately opens Window B, the new window's StateManager cache is populated from `context.globalState` during initialization. If you need to read state in Window B right at startup (e.g., in `common.ts` during `initialize()`), read directly from `context.globalState.get()` instead of StateManager's cache.
|
||||
|
||||
Example pattern (see `lastShownAnnouncementId` and `worktreeAutoOpenPath`):
|
||||
```typescript
|
||||
// Writing (normal pattern)
|
||||
controller.stateManager.setGlobalState("myKey", value)
|
||||
|
||||
// Reading at startup in common.ts (bypass cache)
|
||||
const value = context.globalState.get<string>("myKey")
|
||||
```
|
||||
|
||||
This is only needed for cross-window state read during the brief startup window before StateManager cache is fully usable. Normal state access after initialization should use StateManager.
|
||||
|
||||
## ChatRow Cancelled/Interrupted States
|
||||
When a ChatRow displays a loading/in-progress state (spinner), you must handle what happens when the task is cancelled. This is non-obvious because cancellation doesn't update the message content—you have to infer it from context.
|
||||
|
||||
**The pattern:**
|
||||
1. A message has a `status` field (e.g., `"generating"`, `"complete"`, `"error"`) stored in `message.text` as JSON
|
||||
2. When cancelled mid-operation, the status stays `"generating"` forever—no one updates it
|
||||
3. To detect cancellation, check TWO conditions:
|
||||
- `!isLast` — if this message is no longer the last message, something else happened after it (interrupted)
|
||||
- `lastModifiedMessage?.ask === "resume_task" || "resume_completed_task"` — task was just cancelled and is waiting to resume
|
||||
|
||||
**Example from `generate_explanation`:**
|
||||
```tsx
|
||||
const wasCancelled =
|
||||
explanationInfo.status === "generating" &&
|
||||
(!isLast ||
|
||||
lastModifiedMessage?.ask === "resume_task" ||
|
||||
lastModifiedMessage?.ask === "resume_completed_task")
|
||||
const isGenerating = explanationInfo.status === "generating" && !wasCancelled
|
||||
```
|
||||
|
||||
**Why both checks?**
|
||||
- `!isLast` catches: cancelled → resumed → did other stuff → this old message is stale
|
||||
- `lastModifiedMessage?.ask === "resume_task"` catches: just cancelled, hasn't resumed yet, this message is still technically "last"
|
||||
|
||||
**See also:** `BrowserSessionRow.tsx` uses similar pattern with `isLastApiReqInterrupted` and `isLastMessageResume`.
|
||||
|
||||
**Backend side:** When streaming is cancelled, clean up properly (close tabs, clear comments, etc.) by checking `taskState.abort` after the streaming function returns.
|
||||
@@ -0,0 +1,194 @@
|
||||
# Hotfix Release
|
||||
|
||||
Create a hotfix release by cherry-picking specific commits from main onto the latest release tag.
|
||||
|
||||
## Overview
|
||||
|
||||
This workflow helps you:
|
||||
1. Select specific commits from main to include in a hotfix
|
||||
2. Create a release notes commit on main (changelog + version bump)
|
||||
3. Cherry-pick everything onto the latest release tag
|
||||
4. Tag and push the new release
|
||||
|
||||
## Step 1: Setup and Gather Information
|
||||
|
||||
First, ensure we're on main and up to date:
|
||||
|
||||
```bash
|
||||
git checkout main && git pull origin main
|
||||
```
|
||||
|
||||
Get the latest release tag:
|
||||
|
||||
```bash
|
||||
git tag --sort=-v:refname | head -1
|
||||
```
|
||||
|
||||
## Step 2: Present Commits Since Last Release
|
||||
|
||||
Show all commits on main since the last release tag:
|
||||
|
||||
```bash
|
||||
LAST_TAG=$(git tag --sort=-v:refname | head -1)
|
||||
git log ${LAST_TAG}..HEAD --oneline --format="%h %s (%an)"
|
||||
```
|
||||
|
||||
Also get the commit messages already on the tag (to identify previously cherry-picked commits). Note: Run these as separate commands to avoid shell parsing issues with parentheses in author names:
|
||||
|
||||
```bash
|
||||
LAST_TAG=$(git tag --sort=-v:refname | head -1)
|
||||
PREV_TAG=$(git tag --sort=-v:refname | head -2 | tail -1)
|
||||
```
|
||||
|
||||
```bash
|
||||
git log $PREV_TAG..$LAST_TAG --oneline --format="%s"
|
||||
```
|
||||
|
||||
**Present the list** to the user in a numbered format with commit hash, subject, and author. For any commits whose subject line already appears in the tag's history (previously cherry-picked in an earlier hotfix) or are "Release Notes" commits, add `(already in previous hotfix)` or `(release notes - skip)` after them so the user knows to skip those.
|
||||
|
||||
Ask which commits to include in the hotfix.
|
||||
|
||||
Use the ask_followup_question tool to let the user specify which commits they want (by number or hash).
|
||||
|
||||
## Step 3: Analyze Selected Commits
|
||||
|
||||
For each selected commit:
|
||||
1. Get the full commit message: `git show --no-patch --format="%B" <hash>`
|
||||
2. Get the diff to understand the change: `git show <hash> --stat`
|
||||
3. Find the associated PR if any: `gh pr list --search "<hash>" --state merged --json number,title --jq '.[0]'`
|
||||
|
||||
Build a mental model of what these changes do for the changelog.
|
||||
|
||||
## Step 4: Determine New Version Number
|
||||
|
||||
Parse the current version from package.json and the last tag:
|
||||
|
||||
```bash
|
||||
LAST_TAG=$(git tag --sort=-v:refname | head -1)
|
||||
echo "Last release: $LAST_TAG"
|
||||
cat package.json | grep '"version"'
|
||||
```
|
||||
|
||||
Hotfixes always increment the patch version (e.g., 3.40.0 -> 3.40.1, or 3.40.1 -> 3.40.2).
|
||||
|
||||
**Ask the user to confirm the new version number.**
|
||||
|
||||
## Step 5: Create Release Notes Commit on Main
|
||||
|
||||
On the main branch, create a commit that updates:
|
||||
|
||||
1. **CHANGELOG.md** - Add a new section for the hotfix version at the top:
|
||||
```markdown
|
||||
## [3.40.1]
|
||||
|
||||
- Description of fix 1
|
||||
- Description of fix 2
|
||||
```
|
||||
|
||||
Write clear, user-friendly descriptions based on your analysis of the commits.
|
||||
|
||||
2. **package.json** - Update the version field to the new version
|
||||
|
||||
3. **Delete changesets** for the commits being included in the hotfix. This prevents the changeset bot from including duplicate entries in the next regular release.
|
||||
|
||||
Find and delete the changeset files associated with the selected commits:
|
||||
```bash
|
||||
ls .changeset/
|
||||
```
|
||||
|
||||
Each changeset file in `.changeset/` corresponds to a PR. Read them to identify which ones belong to the commits you're hotfixing, then delete those files.
|
||||
|
||||
**Skip running `npm run install:all`** - the automation handles outdated lockfiles.
|
||||
|
||||
Commit with message format: `v{VERSION} Release Notes (hotfix)`
|
||||
|
||||
In the commit body, mention:
|
||||
- This is for a hotfix release
|
||||
- List the cherry-picked commits that will be included
|
||||
|
||||
```bash
|
||||
git add CHANGELOG.md package.json .changeset/
|
||||
git commit -m "v3.40.1 Release Notes (hotfix)
|
||||
|
||||
Hotfix release including:
|
||||
- <commit1-hash>: <description>
|
||||
- <commit2-hash>: <description>
|
||||
"
|
||||
```
|
||||
|
||||
Push to main:
|
||||
|
||||
```bash
|
||||
git push origin main
|
||||
```
|
||||
|
||||
## Step 6: Build the Hotfix on the Tag
|
||||
|
||||
Checkout the last release tag (detached HEAD):
|
||||
|
||||
```bash
|
||||
LAST_TAG=$(git tag --sort=-v:refname | head -1)
|
||||
git checkout $LAST_TAG
|
||||
```
|
||||
|
||||
Cherry-pick the selected commits in order:
|
||||
|
||||
```bash
|
||||
git cherry-pick <commit1-hash>
|
||||
git cherry-pick <commit2-hash>
|
||||
# ... etc
|
||||
```
|
||||
|
||||
Finally, cherry-pick the release notes commit you just pushed to main:
|
||||
|
||||
```bash
|
||||
# Get the hash of the release notes commit (should be HEAD of main)
|
||||
RELEASE_NOTES_COMMIT=$(git rev-parse main)
|
||||
git cherry-pick $RELEASE_NOTES_COMMIT
|
||||
```
|
||||
|
||||
## Step 7: Tag and Push
|
||||
|
||||
After all cherry-picks are applied successfully:
|
||||
|
||||
```bash
|
||||
# Tag the new release
|
||||
git tag v{VERSION}
|
||||
|
||||
# Push the tag to remote
|
||||
git push origin v{VERSION}
|
||||
```
|
||||
|
||||
## Step 8: Return to Main and Summary
|
||||
|
||||
Return to main branch:
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
```
|
||||
|
||||
**Copy a Slack announcement message to clipboard** with the version and PR links for each included fix:
|
||||
|
||||
```
|
||||
VS Code Hotfix v{VERSION} Published
|
||||
|
||||
- Description of fix 1 https://github.com/cline/cline/pull/{PR_NUMBER}
|
||||
- Description of fix 2 https://github.com/cline/cline/pull/{PR_NUMBER}
|
||||
```
|
||||
|
||||
Present a final summary:
|
||||
- New version: v{VERSION}
|
||||
- Tag pushed: yes
|
||||
- Commits included: (list them)
|
||||
- Slack message copied to clipboard: yes
|
||||
|
||||
Remind the user to:
|
||||
1. Manually trigger the publish release GitHub Action at: https://github.com/cline/cline/actions/workflows/publish.yml (paste `v{VERSION}` as the tag)
|
||||
2. Post the Slack message to announce the hotfix
|
||||
|
||||
## Important Notes
|
||||
|
||||
- This workflow does NOT create a release branch - only tags
|
||||
- The release notes commit goes to main first, then gets cherry-picked to the tag
|
||||
- This keeps main's history accurate while allowing hotfix releases from tags
|
||||
- If cherry-pick conflicts occur, resolve them before continuing
|
||||
@@ -0,0 +1,232 @@
|
||||
# Release
|
||||
|
||||
Prepare and publish a release from the open changeset PR.
|
||||
|
||||
## Overview
|
||||
|
||||
This workflow helps you:
|
||||
1. Find and checkout the open changeset PR
|
||||
2. Clean up the changelog (fix version format, wordsmith entries)
|
||||
3. Push changes back to the PR branch
|
||||
4. Merge with proper commit message format
|
||||
5. Tag and push the release (after verifying the commit)
|
||||
6. Trigger the publish workflow
|
||||
7. Update GitHub release notes
|
||||
8. Provide final summary with Slack announcement
|
||||
|
||||
## Step 1: Find the Changeset PR
|
||||
|
||||
Look for the open changeset PR:
|
||||
|
||||
```bash
|
||||
gh pr list --search "Changeset version bump" --state open --json number,title,headRefName,url
|
||||
```
|
||||
|
||||
If no PR is found, inform the user there's no changeset PR ready. They may need to:
|
||||
- Merge PRs with changesets to main first
|
||||
- Manually trigger the Changeset Converter workflow at: https://github.com/cline/cline/actions/workflows/changeset-converter.yml
|
||||
|
||||
## Step 2: Gather PR Information
|
||||
|
||||
Get the PR details:
|
||||
|
||||
```bash
|
||||
PR_NUMBER=<number from step 1>
|
||||
gh pr view $PR_NUMBER --json body,files,headRefName
|
||||
```
|
||||
|
||||
Checkout the PR branch:
|
||||
|
||||
```bash
|
||||
git fetch origin changeset-release/main
|
||||
git checkout changeset-release/main
|
||||
```
|
||||
|
||||
If the branch has diverged from remote, reset to the remote version:
|
||||
|
||||
```bash
|
||||
git reset --hard origin/changeset-release/main
|
||||
```
|
||||
|
||||
## Step 3: Analyze the Changes
|
||||
|
||||
Read the current CHANGELOG.md to see what the automation generated:
|
||||
|
||||
```bash
|
||||
head -50 CHANGELOG.md
|
||||
```
|
||||
|
||||
Get the version from package.json:
|
||||
|
||||
```bash
|
||||
cat package.json | grep '"version"'
|
||||
```
|
||||
|
||||
**Present to the user:**
|
||||
- The version number that will be released
|
||||
- The raw changelog entries from the changeset PR
|
||||
- Whether this is a patch, minor, or major release
|
||||
|
||||
## Step 4: Clean Up the Changelog
|
||||
|
||||
The changelog needs these fixes:
|
||||
|
||||
1. **Add brackets to version number**: Change `## 3.44.1` to `## [3.44.1]`
|
||||
|
||||
2. **No category headers**: Don't use `### Added`, `### Fixed`, etc. Just a flat list of bullet points.
|
||||
|
||||
3. **Order entries from most important to least important**:
|
||||
- Lead with major new features or significant fixes users care about
|
||||
- End with minor fixes or internal changes
|
||||
|
||||
4. **Write user-friendly descriptions**:
|
||||
- This is for end users, not developers—explain what changed in plain language
|
||||
- Remove commit hashes from the beginning of lines (the automation adds these)
|
||||
- Look at the actual commit diffs (`git show <hash>`) and PRs to understand what changed
|
||||
- Write colorful descriptions that explain the value and impact, not just technical details
|
||||
- Consolidate related changes into single entries when appropriate
|
||||
|
||||
**Ask the user** to review the proposed changelog changes before applying them. Show them:
|
||||
- Current (raw) changelog section
|
||||
- Proposed (cleaned) changelog section
|
||||
|
||||
Once approved, apply the changes to CHANGELOG.md.
|
||||
|
||||
## Step 5: Commit and Push Changes
|
||||
|
||||
After making changelog edits:
|
||||
|
||||
```bash
|
||||
git add CHANGELOG.md
|
||||
git commit -m "Clean up changelog formatting"
|
||||
git push origin changeset-release/main
|
||||
```
|
||||
|
||||
## Step 6: Merge the PR
|
||||
|
||||
**Ask the user to confirm** they're ready to merge.
|
||||
|
||||
Merge the PR with the proper commit message format:
|
||||
|
||||
```bash
|
||||
VERSION=<version from package.json>
|
||||
gh pr merge $PR_NUMBER --squash --subject "v${VERSION} Release Notes" --body ""
|
||||
```
|
||||
|
||||
**If merge is blocked by branch protection:**
|
||||
- Users with admin privileges can add the `--admin` flag to bypass
|
||||
- Users without admin privileges need to get the PR approved through normal review first before merging
|
||||
|
||||
## Step 7: Tag the Release
|
||||
|
||||
After the merge completes, checkout main and pull:
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
git pull origin main
|
||||
```
|
||||
|
||||
**IMPORTANT: Verify the latest commit is the release commit before tagging:**
|
||||
|
||||
```bash
|
||||
git log -1 --oneline
|
||||
```
|
||||
|
||||
Confirm the commit message matches `v{VERSION} Release Notes` (e.g., `v3.44.1 Release Notes`). Do NOT blindly tag HEAD without verification.
|
||||
|
||||
Once verified, tag and push:
|
||||
|
||||
```bash
|
||||
VERSION=<version>
|
||||
git tag v${VERSION}
|
||||
git push origin v${VERSION}
|
||||
```
|
||||
|
||||
## Step 8: Trigger Publish Workflow
|
||||
|
||||
**Copy the tag to clipboard** so the user can easily paste it into the GitHub Actions workflow:
|
||||
|
||||
```bash
|
||||
echo -n "v{VERSION}" | pbcopy
|
||||
```
|
||||
|
||||
**Tell the user to trigger the publish workflow:**
|
||||
1. Go to: https://github.com/cline/cline/actions/workflows/publish.yml
|
||||
2. Select **"release"** for release-type
|
||||
3. Paste **`v{VERSION}`** as the tag (already in clipboard)
|
||||
|
||||
**Wait for the user** to confirm the publish workflow has completed before proceeding.
|
||||
|
||||
## Step 9: Update GitHub Release Notes
|
||||
|
||||
Once the user confirms the publish workflow is done, fetch the auto-generated release content:
|
||||
|
||||
```bash
|
||||
VERSION=<version>
|
||||
gh release view v${VERSION} --json body --jq '.body'
|
||||
```
|
||||
|
||||
The auto-generated release has:
|
||||
- `## What's Changed` - PR list (we'll replace this with our changelog)
|
||||
- `## New Contributors` - First-time contributors (keep this if present)
|
||||
- `**Full Changelog**` - Comparison link (keep this)
|
||||
|
||||
Build the new release body:
|
||||
1. Start with `## What's Changed` header
|
||||
2. Add our changelog content (from CHANGELOG.md for this version)
|
||||
3. Keep the `## New Contributors` section if it exists
|
||||
4. Keep the `**Full Changelog**` link
|
||||
|
||||
Update the release:
|
||||
|
||||
```bash
|
||||
gh release edit v${VERSION} --notes "<new body content>"
|
||||
```
|
||||
|
||||
Verify the release was updated:
|
||||
|
||||
```bash
|
||||
gh release view v${VERSION}
|
||||
```
|
||||
|
||||
## Step 10: Final Summary
|
||||
|
||||
**Copy a Slack announcement message to clipboard** (include the full changelog, not just highlights):
|
||||
|
||||
```bash
|
||||
echo "VS Code v{VERSION} Released
|
||||
|
||||
- Changelog entry 1
|
||||
- Changelog entry 2
|
||||
- Changelog entry 3" | pbcopy
|
||||
```
|
||||
|
||||
**Present a final summary:**
|
||||
- Version released: v{VERSION}
|
||||
- PR merged: #{PR_NUMBER}
|
||||
- Tag pushed: v{VERSION}
|
||||
- Release: https://github.com/cline/cline/releases/tag/v{VERSION}
|
||||
- Slack message copied to clipboard
|
||||
|
||||
**Final reminder:**
|
||||
Post the Slack message to announce the release
|
||||
|
||||
## Handling Edge Cases
|
||||
|
||||
### No changesets found
|
||||
If the changeset PR body shows no changes, inform the user they need to merge PRs with changesets first.
|
||||
|
||||
### Merge conflicts
|
||||
If there are conflicts on the changeset branch, help the user resolve them:
|
||||
```bash
|
||||
git fetch origin main
|
||||
git rebase origin/main
|
||||
# resolve conflicts
|
||||
git push origin changeset-release/main --force-with-lease
|
||||
```
|
||||
|
||||
### User wants to add more changes
|
||||
If the user wants to include additional PRs before releasing:
|
||||
1. Ask them to merge those PRs to main first
|
||||
2. The changeset automation will update the PR automatically
|
||||
3. Re-run this workflow after the PR is updated
|
||||
+28
-3
@@ -72,12 +72,12 @@ POSTHOG_TELEMETRY_ENABLED=true # Enable PostHog telemetry (default: tru
|
||||
# Example configurations:
|
||||
#
|
||||
# Console debugging (logs only):
|
||||
# OTEL_TELEMETRY_ENABLED=1
|
||||
# OTEL_TELEMETRY_ENABLED=true
|
||||
# OTEL_LOGS_EXPORTER=console
|
||||
# TEL_DEBUG_DIAGNOSTICS=true
|
||||
#
|
||||
# OTLP with gRPC (insecure, for local testing):
|
||||
# OTEL_TELEMETRY_ENABLED=1
|
||||
# OTEL_TELEMETRY_ENABLED=true
|
||||
# OTEL_LOGS_EXPORTER=otlp
|
||||
# OTEL_EXPORTER_OTLP_PROTOCOL=grpc
|
||||
# OTEL_EXPORTER_OTLP_ENDPOINT=localhost:4317
|
||||
@@ -85,12 +85,37 @@ POSTHOG_TELEMETRY_ENABLED=true # Enable PostHog telemetry (default: tru
|
||||
# OTEL_EXPORTER_OTLP_HEADERS=authorization=Bearer your-token
|
||||
#
|
||||
# OTLP with HTTP/JSON (production):
|
||||
# OTEL_TELEMETRY_ENABLED=1
|
||||
# OTEL_TELEMETRY_ENABLED=true
|
||||
# OTEL_LOGS_EXPORTER=otlp
|
||||
# OTEL_EXPORTER_OTLP_PROTOCOL=http/json
|
||||
# OTEL_EXPORTER_OTLP_ENDPOINT=https://otel.example.com
|
||||
# OTEL_EXPORTER_OTLP_HEADERS=authorization=Bearer your-token
|
||||
|
||||
# ============================================================================
|
||||
# OBJECT STORE CONFIGURATION
|
||||
# ============================================================================
|
||||
# TO ENABLE S3 OR R2 STORAGE, UNCOMMENT AND FILL IN THE FOLLOWING:
|
||||
# CLINE_STORAGE_ADAPTER="s3" # Options: "s3" or "r2"
|
||||
# CLINE_STORAGE_BUCKET="cline"
|
||||
# CLINE_STORAGE_ACCESS_KEY_ID="key"
|
||||
# CLINE_STORAGE_SECRET_ACCESS_KEY="secrets"
|
||||
#
|
||||
# [OPTIONAL FIELDS FOR R2]
|
||||
# CLINE_STORAGE_ACCOUNT_ID = "account-id"
|
||||
# Default R2 endpoint (if not set): "https://<CLINE_STORAGE_ACCOUNT_ID>.r2.cloudflarestorage.com"
|
||||
# CLINE_STORAGE_ENDPOINT = "http://localhost:8333"
|
||||
#
|
||||
# [OPTIONAL FIELDS FOR S3]
|
||||
# CLINE_STORAGE_REGION = "us-west-1" # AWS Bucket Region (default: "us-east-1")
|
||||
# Default S3 endpoint (if not set): "https://s3.<CLINE_STORAGE_REGION>.amazonaws.com"
|
||||
# CLINE_STORAGE_ENDPOINT = "http://localhost:8333"
|
||||
#
|
||||
# [OPTIONAL FIELDS FOR ALL STORAGE TYPES]
|
||||
# CLINE_STORAGE_SYNC_INTERVAL_MS = 30000 # Interval for sync worker in milliseconds
|
||||
# CLINE_STORAGE_SYNC_MAX_RETRIES = 5 # Max retries for failed sync operations
|
||||
# CLINE_STORAGE_SYNC_BATCH_SIZE = 10 # Number of files to sync in each batch
|
||||
# CLINE_STORAGE_SYNC_BACKFILL_ENABLED = false # Enable backfill of existing data on startup
|
||||
|
||||
# ============================================================================
|
||||
# OPTIONAL DEVELOPMENT SETTINGS
|
||||
# ============================================================================
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/docs/
|
||||
/.github/ @saoudrizwan @garoth @sjf
|
||||
/README.md @saoudrizwan @nickbaumann98
|
||||
/src/core/storage/ @celestial-vault
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
name: Claude Issue Triage
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
# Manual trigger for backfilling existing issues. Run from terminal:
|
||||
# gh workflow run claude-issue-triage.yml -f issue_number=1234
|
||||
# Or batch process:
|
||||
# gh issue list --state open --limit 10 --json number --jq '.[].number' | while read num; do
|
||||
# gh workflow run claude-issue-triage.yml -f issue_number=$num
|
||||
# sleep 60
|
||||
# done
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
issue_number:
|
||||
description: 'Issue number to triage'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
claude-issue-triage:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
# SECURITY: These permissions are intentionally restrictive.
|
||||
# - contents: read -> Claude can read the codebase but CANNOT write/push any code
|
||||
# - issues: write -> Claude can comment and add labels (the only write access needed)
|
||||
# - pull-requests: read -> Claude can view PR context but CANNOT create PRs
|
||||
# This ensures that even if a malicious user attempts prompt injection via issue content,
|
||||
# Claude cannot modify repository code, create branches, or open PRs.
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Run Issue Response & Triage
|
||||
id: triage
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
allowed_non_write_users: "*"
|
||||
# Allow all tools - security is enforced by GitHub permissions above (contents: read, issues: write)
|
||||
claude_args: --model claude-opus-4-5-20251101 --allowedTools "Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch"
|
||||
prompt: |
|
||||
You're a GitHub issue first responder for the open source Cline repository.
|
||||
|
||||
**Issue:** #${{ github.event.issue.number || inputs.issue_number }}
|
||||
**Title:** ${{ github.event.issue.title || 'See issue details below' }}
|
||||
**Author:** @${{ github.event.issue.user.login || 'See issue details below' }}
|
||||
|
||||
## Your job
|
||||
|
||||
Investigate this issue thoroughly, then post a single helpful comment that helps the user and gives maintainers the context they need.
|
||||
|
||||
## Investigation
|
||||
|
||||
Start by reading the full issue:
|
||||
gh issue view ${{ github.event.issue.number || inputs.issue_number }}
|
||||
|
||||
### Search for duplicates and related issues
|
||||
|
||||
Search thoroughly for existing issues that match this one:
|
||||
gh issue list --search "<keywords from the issue>" --state all --limit 30
|
||||
gh issue list --search "<error messages>" --state all --limit 20
|
||||
gh issue list --search "<affected feature/component>" --state all --limit 20
|
||||
|
||||
For each relevant issue you find, read it including its comments:
|
||||
gh issue view <number> --comments
|
||||
|
||||
You're looking for:
|
||||
- **Duplicates**: Issues describing the same problem. Link to them and explain why you think they're duplicates. If closed, check how they were resolved - the solution might apply here.
|
||||
- **Related issues**: Similar problems or context that could help. Pull useful information from their comments (workarounds others found, debugging steps that helped, maintainer explanations). Link to them and explain the connection.
|
||||
|
||||
If there are closed issues with solutions, surface those solutions prominently - this might immediately solve the user's problem.
|
||||
|
||||
### Analyze recent changes (ALWAYS DO THIS)
|
||||
|
||||
Many issues are regressions from recent releases. **Always** check what changed recently:
|
||||
gh release list --limit 10
|
||||
gh pr list --state merged --limit 50 --json number,title,mergedAt,author,body
|
||||
|
||||
Look for PRs merged in the last few weeks that might correlate with the issue. If you find a likely connection:
|
||||
gh pr view <number>
|
||||
gh pr diff <number>
|
||||
git log --since="1 month ago" --oneline -- <relevant paths>
|
||||
git show <commit>
|
||||
|
||||
**Always include your findings in your comment:**
|
||||
- If you find a regression, call it out explicitly: which PR/commit likely caused it, who authored it, what changed, and suggest a fix direction if you can see one.
|
||||
- If you don't find anything related, still mention it: "I analyzed recent PRs and releases but didn't find any changes that seem related to this issue."
|
||||
|
||||
### Search the codebase
|
||||
|
||||
Find the relevant code:
|
||||
- Use grep/find to locate code related to the issue
|
||||
- Key areas: `src/api/` (providers/models), `src/core/prompts/` (tools/prompts), platform-specific code for VS Code vs JetBrains
|
||||
|
||||
### Find documentation
|
||||
|
||||
Cline docs are at **https://docs.cline.bot/** and built with Mintlify from the `docs/` directory.
|
||||
|
||||
The URL structure maps directly to the file structure:
|
||||
- `docs/getting-started/selecting-your-model.mdx` → https://docs.cline.bot/getting-started/selecting-your-model
|
||||
- `docs/troubleshooting.mdx` → https://docs.cline.bot/troubleshooting
|
||||
- Headings become anchors: `## Which Model` → `#which-model`
|
||||
|
||||
Search the `docs/` directory to find relevant documentation, then construct URLs to link users to:
|
||||
```bash
|
||||
ls docs/
|
||||
grep -r "keyword" docs/ --include="*.mdx" -l
|
||||
```
|
||||
|
||||
### Identify subject matter experts
|
||||
|
||||
For issues that clearly need engineering attention:
|
||||
git log --since="6 months ago" --format="%an" -- <relevant paths> | sort | uniq -c | sort -rn | head -5
|
||||
|
||||
Cross-reference with GitHub usernames. Include in your response (@mention, do NOT assign):
|
||||
|
||||
| SME | Reason |
|
||||
|-----|--------|
|
||||
| @username1 | Authored PR #X which modified this area |
|
||||
| @username2 | Primary contributor to affected file |
|
||||
|
||||
## Weak model detection
|
||||
|
||||
Many issues are caused by users running small or non-frontier models that don't tool-call reliably. Signs include:
|
||||
- Model failing to use tools correctly
|
||||
- Nonsensical or malformed responses
|
||||
- User is running a small/local model or older model version
|
||||
|
||||
If this looks like a weak model issue, kindly suggest they try reproducing with Claude Sonnet and report back if it persists. Link to https://docs.cline.bot/getting-started/selecting-your-model if helpful. Still label and triage normally.
|
||||
|
||||
## Your comment
|
||||
|
||||
Write a single comment as a helpful community member. Be conversational, not robotic. Include what's relevant:
|
||||
|
||||
- **Helpful response** - Answer their question, suggest a fix, provide a workaround. If you found solutions in related closed issues, surface those prominently.
|
||||
- **Duplicates and related issues** - Link to any you found and explain why they're duplicates/related. Summarize useful context from their comments.
|
||||
- **Regression analysis** - If this looks like a regression, explain what change likely caused it, link to the PR/commit, and tag the author.
|
||||
- **Clarifying questions** - If you need more info, ask specific questions. Don't ask for things already provided.
|
||||
- **SME table** - Include the table above if this needs engineering attention. Don't tag people for questions with obvious answers or weak-model issues.
|
||||
- **Context for maintainers** - Relevant code paths, what you found. Keep it concise.
|
||||
- **Docs links** - If there's relevant documentation, link to it naturally in your response as a recommendation (e.g., "For more details, check out [the Ollama setup guide](url)"). Do NOT add a "Sources" section at the end - integrate doc links into your response where they're helpful.
|
||||
- **Possible Duplicates section** - ALWAYS include a "Possible Duplicates" section at the end of your comment listing issues that might be duplicates so maintainers can quickly close if appropriate. If none found, say "No obvious duplicates found."
|
||||
|
||||
## Labels
|
||||
First, retrieve all available labels and read their descriptions to understand what each is for:
|
||||
gh label list --json name,description --limit 100
|
||||
|
||||
Then apply the appropriate labels based on your analysis. Only use labels from the list above—do not create new labels.
|
||||
gh issue edit ${{ github.event.issue.number || inputs.issue_number }} --add-label "label1,label2"
|
||||
|
||||
If your regression analysis found a likely culprit (a recent PR/commit that probably caused this issue), add the "Regression" label:
|
||||
gh issue edit ${{ github.event.issue.number || inputs.issue_number }} --add-label "Regression"
|
||||
|
||||
IMPORTANT: After posting your comment, add the "Bot Responded" label to indicate this issue has received an automated response:
|
||||
gh issue edit ${{ github.event.issue.number || inputs.issue_number }} --add-label "Bot Responded"
|
||||
|
||||
## Remember
|
||||
|
||||
- **This is a one-time automated response** - you will NOT see their reply or respond again. Never say things like "I can help you", "let me know", "once I have that info", or "I can give you more targeted help" - you won't be there to follow up. If you ask clarifying questions, frame them for the maintainers who will follow up, e.g., "If you can share X, that would help the maintainers diagnose this."
|
||||
- Don't be formulaic. Respond to what the issue actually needs.
|
||||
- Surface solutions from past issues - often the fastest path to helping.
|
||||
- Connecting regressions to specific changes is extremely valuable.
|
||||
- Link issues with #number so they're clickable.
|
||||
@@ -0,0 +1,272 @@
|
||||
name: Claude PR Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, ready_for_review]
|
||||
# Manual trigger for backfilling existing PRs. Run from terminal:
|
||||
# gh workflow run claude-pr-review.yml -f pr_number=1234
|
||||
# Or batch process open PRs:
|
||||
# gh pr list --state open --limit 10 --json number --jq '.[].number' | while read num; do
|
||||
# gh workflow run claude-pr-review.yml -f pr_number=$num
|
||||
# sleep 60
|
||||
# done
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'PR number to review'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
claude-pr-review:
|
||||
# Runs on PR opened/ready_for_review (skips drafts) or manual trigger for backfilling
|
||||
if: |
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.draft == false) ||
|
||||
github.event_name == 'workflow_dispatch'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
|
||||
# SECURITY: These permissions are intentionally restrictive.
|
||||
# - contents: read -> Claude can read the codebase but CANNOT write/push any code
|
||||
# - pull-requests: write -> Claude can post reviews and inline suggestions
|
||||
# - issues: read -> Claude can search for related issues
|
||||
# NOTE: Even with pull-requests: write, Claude CANNOT merge PRs because branch protection
|
||||
# requires 1 approval from a Code Owner. The GITHUB_TOKEN cannot bypass this.
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get PR number
|
||||
id: pr
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
|
||||
echo "number=${{ inputs.pr_number }}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "number=${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Run PR Review
|
||||
id: review
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
allowed_non_write_users: "*"
|
||||
claude_args: --model claude-opus-4-5-20251101 --allowedTools "Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch"
|
||||
prompt: |
|
||||
You're a GitHub PR reviewer for the open source Cline repository. Your goal is to give the PR author helpful feedback and give maintainers the context they need to review efficiently.
|
||||
|
||||
PR: #${{ steps.pr.outputs.number }}
|
||||
|
||||
## Gather context
|
||||
|
||||
```bash
|
||||
# Get full PR details
|
||||
gh pr view ${{ steps.pr.outputs.number }} --json number,title,body,author,createdAt,updatedAt,isDraft,labels,commits,files,additions,deletions,changedFiles,baseRefName,headRefName,mergeable,reviewDecision
|
||||
|
||||
# Get the diff
|
||||
gh pr diff ${{ steps.pr.outputs.number }}
|
||||
|
||||
# Check CI status
|
||||
gh pr checks ${{ steps.pr.outputs.number }}
|
||||
|
||||
# Get existing review comments (to understand context and your previous feedback)
|
||||
gh api repos/${{ github.repository }}/pulls/${{ steps.pr.outputs.number }}/comments --jq '.[] | {user: .user.login, body: .body, path: .path, created_at: .created_at}'
|
||||
|
||||
# Get conversation comments
|
||||
gh pr view ${{ steps.pr.outputs.number }} --comments
|
||||
```
|
||||
|
||||
If this is a re-review (workflow_dispatch event):
|
||||
Read your previous comments carefully. Understand what you asked for before.
|
||||
Check if new commits or comments address your previous feedback.
|
||||
|
||||
## Check contributing guidelines
|
||||
|
||||
Flag (but don't block) if:
|
||||
- Missing changeset - For user-facing changes, check if there's a `.changeset/` file:
|
||||
```bash
|
||||
gh pr diff ${{ steps.pr.outputs.number }} --name-only | grep '.changeset/' || echo "No changeset found"
|
||||
```
|
||||
If missing, ask them to run `npm run changeset`
|
||||
- Missing tests - New features should have tests
|
||||
|
||||
## Find related issues and PRs
|
||||
|
||||
Search thoroughly for context that might help with the review:
|
||||
|
||||
```bash
|
||||
# Find related issues for context
|
||||
gh issue list --search "<keywords from the PR>" --state all --limit 30
|
||||
gh issue list --search "<error messages or feature names>" --state all --limit 20
|
||||
|
||||
# Find similar PRs for reference
|
||||
gh pr list --search "<keywords>" --state all --limit 30
|
||||
```
|
||||
|
||||
For each relevant issue or PR you find, read it including comments:
|
||||
```bash
|
||||
gh issue view <number> --comments
|
||||
gh pr view <number> --comments
|
||||
```
|
||||
|
||||
Look for:
|
||||
- Open issues this PR might fix that weren't linked in the description
|
||||
- Similar PRs that went through review - what feedback did they get? What patterns did they follow?
|
||||
- Context from maintainer discussions that could inform your review
|
||||
|
||||
## Find subject matter experts
|
||||
|
||||
For files changed in this PR, find who knows the code best:
|
||||
```bash
|
||||
# Get files changed
|
||||
gh pr diff ${{ steps.pr.outputs.number }} --name-only
|
||||
|
||||
# For each relevant path, find contributors
|
||||
git log --since="6 months ago" --format="%an" -- <path> | sort | uniq -c | sort -rn | head -5
|
||||
```
|
||||
|
||||
Cross-reference git authors with GitHub usernames. Include an SME table in your response:
|
||||
|
||||
| SME | Reason |
|
||||
|-----|--------|
|
||||
| @username1 | Authored PR #X which modified this area |
|
||||
| @username2 | Primary contributor to affected file (15 commits in 6 months) |
|
||||
| @username3 | Reviewed similar PR #Y with extensive feedback |
|
||||
|
||||
## Deep code review
|
||||
|
||||
This is the most important part. Don't just look for syntax issues - understand what the PR is trying to achieve and whether the implementation is the right approach.
|
||||
|
||||
Step 1: Understand the intent
|
||||
Read the PR description and understand what the author is trying to accomplish. What problem are they solving? What feature are they adding?
|
||||
|
||||
Step 2: Form your own opinion first
|
||||
Before analyzing their code, think about how YOU would implement this feature or fix. What files would you touch? What patterns would you follow? What edge cases would you handle?
|
||||
|
||||
Step 3: Compare approaches
|
||||
Now look at their implementation. How does it compare to what you would have done?
|
||||
- Is their approach better in some ways? Note what they did well.
|
||||
- Is their approach missing something? Be specific about what and why.
|
||||
- Are there edge cases they haven't considered?
|
||||
- Does it follow the patterns established in similar parts of the codebase?
|
||||
|
||||
Step 4: Look at the bigger picture
|
||||
- What other files or systems does this change interact with?
|
||||
- Could this break anything else?
|
||||
- Is there additional work needed beyond this PR to complete the feature?
|
||||
- Does this fit well with the overall architecture?
|
||||
|
||||
Step 5: Find reference implementations
|
||||
Look for similar changes in the codebase:
|
||||
```bash
|
||||
git log --oneline --all --grep="<relevant keywords>" | head -20
|
||||
git log --oneline -- <similar files> | head -20
|
||||
```
|
||||
|
||||
If this is adding a new API provider, look at how other providers are implemented.
|
||||
If this is adding a new feature, look at how similar features were added.
|
||||
Note where their implementation aligns with or diverges from established patterns.
|
||||
|
||||
Step 6: Standard code review checks
|
||||
- DRY: Is there duplicated code that could be extracted?
|
||||
- Error handling: Are errors handled appropriately?
|
||||
- Security: Any injection risks, credential exposure, unsafe dependencies?
|
||||
- Performance: Any obvious inefficiencies, memory leaks, N+1 patterns?
|
||||
- Types: Is TypeScript used correctly? Any unsafe type assertions?
|
||||
- Naming: Are variables and functions named clearly?
|
||||
- Comments: Is complex logic explained? Are there outdated comments?
|
||||
|
||||
## Inline code suggestions
|
||||
|
||||
For specific code improvements, use GitHub's suggestion syntax via `gh api`.
|
||||
This creates suggestions the author can commit with one click.
|
||||
|
||||
Single-line suggestion:
|
||||
```bash
|
||||
gh api repos/${{ github.repository }}/pulls/${{ steps.pr.outputs.number }}/reviews \
|
||||
-X POST \
|
||||
-f commit_id="$(gh pr view ${{ steps.pr.outputs.number }} --json headRefOid -q .headRefOid)" \
|
||||
-f event="COMMENT" \
|
||||
-f body="" \
|
||||
-F comments='[
|
||||
{
|
||||
"path": "src/example.ts",
|
||||
"line": 42,
|
||||
"body": "Consider simplifying:\n\n```suggestion\nconst result = items.filter(Boolean);\n```"
|
||||
}
|
||||
]'
|
||||
```
|
||||
|
||||
Multi-line suggestion (replacing lines 40-45):
|
||||
```bash
|
||||
gh api repos/${{ github.repository }}/pulls/${{ steps.pr.outputs.number }}/reviews \
|
||||
-X POST \
|
||||
-f commit_id="$(gh pr view ${{ steps.pr.outputs.number }} --json headRefOid -q .headRefOid)" \
|
||||
-f event="COMMENT" \
|
||||
-f body="" \
|
||||
-F comments='[
|
||||
{
|
||||
"path": "src/example.ts",
|
||||
"start_line": 40,
|
||||
"line": 45,
|
||||
"body": "This can be simplified:\n\n```suggestion\nconst simplified = doThing();\n```"
|
||||
}
|
||||
]'
|
||||
```
|
||||
|
||||
Use inline suggestions for concrete improvements. Use regular comments for questions or broader feedback.
|
||||
|
||||
## Post your review
|
||||
|
||||
After your investigation, post a single helpful comment that helps the author and gives maintainers context.
|
||||
|
||||
Start with a warm thank you for their contribution. Be conversational, not robotic.
|
||||
|
||||
Include what's relevant:
|
||||
- In-depth explanation of what the PR does - Be comprehensive. A maintainer should be able to read this section and fully understand the author's intent, why they made the changes, how they implemented it, and what files/systems are affected. Don't just summarize - explain.
|
||||
- Related issues/PRs you found that provide useful context (link to them)
|
||||
- Your review findings (issues to address, suggestions, etc.)
|
||||
- Clear next steps for the author
|
||||
|
||||
Include a "For Maintainers" section with:
|
||||
- Anything else useful to help the maintainer resolve this PR
|
||||
- Related issues/PRs with context on why they're relevant
|
||||
- Open issues this PR might fix that weren't linked in the description
|
||||
- Your recommendation: merge as-is, needs changes, needs discussion, close, etc.
|
||||
- SME table - who should review this and why
|
||||
|
||||
For the SME table:
|
||||
| SME | Reason |
|
||||
|-----|--------|
|
||||
| @username | Primary contributor to affected files |
|
||||
|
||||
## Update labels
|
||||
|
||||
Add appropriate labels based on your analysis:
|
||||
```bash
|
||||
gh label list --json name,description --limit 100
|
||||
gh pr edit ${{ steps.pr.outputs.number }} --add-label "label1,label2"
|
||||
```
|
||||
|
||||
When done, add the reviewed label:
|
||||
```bash
|
||||
gh pr edit ${{ steps.pr.outputs.number }} --add-label "Bot Reviewed"
|
||||
```
|
||||
|
||||
## Remember
|
||||
|
||||
- This is a one-time automated response - you will NOT see their reply or respond again. Never say things like "let me know if you have questions", "I can help you with", or "feel free to ask" - you won't be there to follow up. Frame any questions for the maintainers who will follow up.
|
||||
- Be helpful and welcoming - Many contributors are new to the project
|
||||
- Be specific - Point to exact lines and suggest fixes, don't give vague feedback
|
||||
- Think deeply - Don't just surface-level review, understand the intent and evaluate the approach
|
||||
- Use inline suggestions - Make it easy for authors to accept changes
|
||||
- You're a first-pass reviewer - A human maintainer will do final approval
|
||||
@@ -0,0 +1,312 @@
|
||||
name: Cline PR Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
[opened, ready_for_review]
|
||||
# Manual trigger for backfilling existing PRs. Run from terminal:
|
||||
# gh workflow run cline-pr-review.yml -f pr_number=1234
|
||||
# Or batch process open PRs:
|
||||
# gh pr list --state open --limit 10 --json number --jq '.[].number' | while read num; do
|
||||
# gh workflow run cline-pr-review.yml -f pr_number=$num
|
||||
# sleep 60
|
||||
# done
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: "PR number to review"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: pr-review-${{ github.event.pull_request.number || inputs.pr_number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
cline-pr-review:
|
||||
# Runs on PR opened/ready_for_review (skips drafts) or manual trigger for backfilling
|
||||
if: |
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.draft == false) ||
|
||||
github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
# SECURITY: These permissions are intentionally restrictive.
|
||||
# - contents: read -> cline can read the codebase but CANNOT write/push any code
|
||||
# - pull-requests: write -> cline can post reviews and inline suggestions
|
||||
# - issues: read -> cline can search for related issues
|
||||
# NOTE: Even with pull-requests: write, cline CANNOT merge PRs because branch protection
|
||||
# requires 1 approval from a Code Owner. The GITHUB_TOKEN cannot bypass this.
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
|
||||
- name: Install and Verify Cline CLI
|
||||
run: |
|
||||
npx cline version # verify installation
|
||||
|
||||
- name: Configure Cline with Anthropic
|
||||
run: |
|
||||
npx cline auth --provider anthropic \
|
||||
--apikey "${{ secrets.ANTHROPIC_API_KEY }}" \
|
||||
--modelid claude-opus-4-5-20251101
|
||||
|
||||
- name: Get PR number
|
||||
id: pr
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
|
||||
echo "number=${{ inputs.pr_number }}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "number=${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Review PR with Cline
|
||||
env:
|
||||
PR_NUMBER: ${{ steps.pr.outputs.number }}
|
||||
GITHUB_REPO: ${{ github.repository }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
CLINE_COMMAND_PERMISSIONS: |
|
||||
{
|
||||
"allow": [
|
||||
"gh pr diff *",
|
||||
"gh pr view *",
|
||||
"gh pr checks *",
|
||||
"gh pr list *",
|
||||
"gh label list *",
|
||||
"gh issue list *",
|
||||
"gh issue view *",
|
||||
"git log *",
|
||||
"gh pr comment ${{ steps.pr.outputs.number }} *",
|
||||
"gh pr edit ${{ steps.pr.outputs.number }} *",
|
||||
"gh api repos/${{ github.repository }}/pulls/${{ steps.pr.outputs.number }}/comments *",
|
||||
"gh api repos/${{ github.repository }}/pulls/${{ steps.pr.outputs.number }}/reviews *"
|
||||
]
|
||||
}
|
||||
run: |
|
||||
npx cline --yolo 'You'\''re a GitHub PR reviewer for the open source Cline repository. Your goal is to give the PR author helpful feedback and give maintainers the context they need to review efficiently.
|
||||
|
||||
PR: #'"${PR_NUMBER}"'
|
||||
|
||||
## Gather context
|
||||
|
||||
```bash
|
||||
# Get full PR details
|
||||
gh pr view '"${PR_NUMBER}"' --json number,title,body,author,createdAt,updatedAt,isDraft,labels,commits,files,additions,deletions,changedFiles,baseRefName,headRefName,mergeable,reviewDecision
|
||||
|
||||
# Get the diff
|
||||
gh pr diff '"${PR_NUMBER}"'
|
||||
|
||||
# Check CI status
|
||||
gh pr checks '"${PR_NUMBER}"'
|
||||
|
||||
# Get existing review comments (to understand context and your previous feedback)
|
||||
gh api repos/'"${GITHUB_REPO}"'/pulls/'"${PR_NUMBER}"'/comments --jq '\''.[] | {user: .user.login, body: .body, path: .path, created_at: .created_at}'\''
|
||||
|
||||
# Get conversation comments
|
||||
gh pr view '"${PR_NUMBER}"' --comments
|
||||
```
|
||||
|
||||
If this is a re-review (workflow_dispatch event):
|
||||
Read your previous comments carefully. Understand what you asked for before.
|
||||
Check if new commits or comments address your previous feedback.
|
||||
|
||||
## Check contributing guidelines
|
||||
|
||||
Flag (but don'\''t block) if:
|
||||
- Missing changeset - For user-facing changes, check if there'\''s a `.changeset/` file:
|
||||
```bash
|
||||
gh pr diff '"${PR_NUMBER}"' --name-only | grep '\''.changeset/'\'' || echo '\''No changeset found'\''
|
||||
```
|
||||
If missing, ask them to run `npm run changeset`
|
||||
- Missing tests - New features should have tests
|
||||
|
||||
## Find related issues and PRs
|
||||
|
||||
Search thoroughly for context that might help with the review:
|
||||
|
||||
```bash
|
||||
# Find related issues for context
|
||||
gh issue list --search '\''<keywords from the PR>'\'' --state all --limit 30
|
||||
gh issue list --search '\''<error messages or feature names>'\'' --state all --limit 20
|
||||
|
||||
# Find similar PRs for reference
|
||||
gh pr list --search '\''<keywords>'\'' --state all --limit 30
|
||||
```
|
||||
|
||||
For each relevant issue or PR you find, read it including comments:
|
||||
```bash
|
||||
gh issue view <number> --comments
|
||||
gh pr view <number> --comments
|
||||
```
|
||||
|
||||
Look for:
|
||||
- Open issues this PR might fix that weren'\''t linked in the description
|
||||
- Similar PRs that went through review - what feedback did they get? What patterns did they follow?
|
||||
- Context from maintainer discussions that could inform your review
|
||||
|
||||
## Find subject matter experts
|
||||
|
||||
For files changed in this PR, find who knows the code best:
|
||||
```bash
|
||||
# Get files changed
|
||||
gh pr diff '"${PR_NUMBER}"' --name-only
|
||||
|
||||
# For each relevant path, find contributors
|
||||
git log --since='\''6 months ago'\'' --format='\''%an'\'' -- <path> | sort | uniq -c | sort -rn | head -5
|
||||
```
|
||||
|
||||
Cross-reference git authors with GitHub usernames. Include an SME table in your response:
|
||||
|
||||
| SME | Reason |
|
||||
|-----|--------|
|
||||
| @username1 | Authored PR #X which modified this area |
|
||||
| @username2 | Primary contributor to affected file (15 commits in 6 months) |
|
||||
| @username3 | Reviewed similar PR #Y with extensive feedback |
|
||||
|
||||
## Bash command usage
|
||||
|
||||
Don'\''t use operators like `|`, `&&`, or `;` - run each command separately and analyze the output.
|
||||
|
||||
When referencing command outputs, quote them properly to avoid formatting issues.
|
||||
|
||||
## Deep code review
|
||||
|
||||
This is the most important part. Don'\''t just look for syntax issues - understand what the PR is trying to achieve and whether the implementation is the right approach.
|
||||
|
||||
Step 1: Understand the intent
|
||||
Read the PR description and understand what the author is trying to accomplish. What problem are they solving? What feature are they adding?
|
||||
|
||||
Step 2: Form your own opinion first
|
||||
Before analyzing their code, think about how YOU would implement this feature or fix. What files would you touch? What patterns would you follow? What edge cases would you handle?
|
||||
|
||||
Step 3: Compare approaches
|
||||
Now look at their implementation. How does it compare to what you would have done?
|
||||
- Is their approach better in some ways? Note what they did well.
|
||||
- Is their approach missing something? Be specific about what and why.
|
||||
- Are there edge cases they haven'\''t considered?
|
||||
- Does it follow the patterns established in similar parts of the codebase?
|
||||
|
||||
Step 4: Look at the bigger picture
|
||||
- What other files or systems does this change interact with?
|
||||
- Could this break anything else?
|
||||
- Is there additional work needed beyond this PR to complete the feature?
|
||||
- Does this fit well with the overall architecture?
|
||||
|
||||
Step 5: Find reference implementations
|
||||
Look for similar changes in the codebase:
|
||||
```bash
|
||||
git log --oneline --all --grep='\''<relevant keywords>'\'' | head -20
|
||||
git log --oneline -- <similar files> | head -20
|
||||
```
|
||||
|
||||
If this is adding a new API provider, look at how other providers are implemented.
|
||||
If this is adding a new feature, look at how similar features were added.
|
||||
Note where their implementation aligns with or diverges from established patterns.
|
||||
|
||||
Step 6: Standard code review checks
|
||||
- DRY: Is there duplicated code that could be extracted?
|
||||
- Error handling: Are errors handled appropriately?
|
||||
- Security: Any injection risks, credential exposure, unsafe dependencies?
|
||||
- Performance: Any obvious inefficiencies, memory leaks, N+1 patterns?
|
||||
- Types: Is TypeScript used correctly? Any unsafe type assertions?
|
||||
- Naming: Are variables and functions named clearly?
|
||||
- Comments: Is complex logic explained? Are there outdated comments?
|
||||
|
||||
## Inline code suggestions
|
||||
|
||||
For specific code improvements, use GitHub'\''s suggestion syntax via `gh api`.
|
||||
This creates suggestions the author can commit with one click.
|
||||
|
||||
Single-line suggestion:
|
||||
```bash
|
||||
gh api repos/'"${GITHUB_REPO}"'/pulls/'"${PR_NUMBER}"'/reviews \
|
||||
-X POST \
|
||||
-f commit_id="$(gh pr view '"${PR_NUMBER}"' --json headRefOid -q .headRefOid)" \
|
||||
-f event='\''COMMENT'\'' \
|
||||
-f body='\'''\'' \
|
||||
-F comments='\''[
|
||||
{
|
||||
"path": "src/example.ts",
|
||||
"line": 42,
|
||||
"body": "Consider simplifying:\n\n```suggestion\nconst result = items.filter(Boolean);\n```"
|
||||
}
|
||||
]'\''
|
||||
```
|
||||
|
||||
Multi-line suggestion (replacing lines 40-45):
|
||||
```bash
|
||||
gh api repos/'"${GITHUB_REPO}"'/pulls/'"${PR_NUMBER}"'/reviews \
|
||||
-X POST \
|
||||
-f commit_id="$(gh pr view '"${PR_NUMBER}"' --json headRefOid -q .headRefOid)" \
|
||||
-f event='\''COMMENT'\'' \
|
||||
-f body='\'''\'' \
|
||||
-F comments='\''[
|
||||
{
|
||||
"path": "src/example.ts",
|
||||
"start_line": 40,
|
||||
"line": 45,
|
||||
"body": "This can be simplified:\n\n```suggestion\nconst simplified = doThing();\n```"
|
||||
}
|
||||
]'\''
|
||||
```
|
||||
|
||||
Use inline suggestions for concrete improvements. Use regular comments for questions or broader feedback.
|
||||
|
||||
## Post your review
|
||||
|
||||
After your investigation, post a single helpful comment that helps the author and gives maintainers context.
|
||||
|
||||
Start with a warm thank you for their contribution. Be conversational, not robotic.
|
||||
|
||||
Include what'\''s relevant:
|
||||
- In-depth explanation of what the PR does - Be comprehensive. A maintainer should be able to read this section and fully understand the author'\''s intent, why they made the changes, how they implemented it, and what files/systems are affected. Don'\''t just summarize - explain.
|
||||
- Related issues/PRs you found that provide useful context (link to them)
|
||||
- Your review findings (issues to address, suggestions, etc.)
|
||||
- Clear next steps for the author
|
||||
|
||||
Include a '\''For Maintainers'\'' section with:
|
||||
- Anything else useful to help the maintainer resolve this PR
|
||||
- Related issues/PRs with context on why they'\''re relevant
|
||||
- Open issues this PR might fix that weren'\''t linked in the description
|
||||
- Your recommendation: merge as-is, needs changes, needs discussion, close, etc.
|
||||
- SME table - who should review this and why
|
||||
|
||||
For the SME table:
|
||||
| SME | Reason |
|
||||
|-----|--------|
|
||||
| @username | Primary contributor to affected files |
|
||||
|
||||
## Update labels
|
||||
|
||||
Add appropriate labels based on your analysis:
|
||||
```bash
|
||||
gh label list --json name,description --limit 100
|
||||
gh pr edit '"${PR_NUMBER}"' --add-label '\''label1,label2'\''
|
||||
```
|
||||
|
||||
When done, add the reviewed label:
|
||||
```bash
|
||||
gh pr edit '"${PR_NUMBER}"' --add-label '\''Bot Reviewed'\''
|
||||
```
|
||||
|
||||
## Remember
|
||||
|
||||
- This is a one-time automated response - you will NOT see their reply or respond again. Never say things like '\''let me know if you have questions'\'', '\''I can help you with'\'', or '\''feel free to ask'\'' - you won'\''t be there to follow up. Frame any questions for the maintainers who will follow up.
|
||||
- Be helpful and welcoming - Many contributors are new to the project
|
||||
- Be specific - Point to exact lines and suggest fixes, don'\''t give vague feedback
|
||||
- Think deeply - Don'\''t just surface-level review, understand the intent and evaluate the approach
|
||||
- Use inline suggestions - Make it easy for authors to accept changes
|
||||
- You'\''re a first-pass reviewer - A human maintainer will do final approval'
|
||||
@@ -0,0 +1,130 @@
|
||||
name: Publish NPM Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
confirm_publish:
|
||||
description: 'Type "publish" to confirm you want to publish to NPM'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
checks: write # Required by test workflow
|
||||
pull-requests: write # Required by test workflow
|
||||
|
||||
jobs:
|
||||
test:
|
||||
uses: ./.github/workflows/test.yml
|
||||
|
||||
publish-npm-release:
|
||||
needs: test
|
||||
name: Publish Cline CLI to NPM
|
||||
if: github.repository == 'cline/cline' && github.ref == 'refs/heads/main' && github.event.inputs.confirm_publish == 'publish'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.24'
|
||||
cache-dependency-path: cli/go.sum
|
||||
|
||||
# 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: Read release version
|
||||
id: version
|
||||
run: |
|
||||
# Read version from cli/package.json (stable version)
|
||||
VERSION=$(node -p "require('./cli/package.json').version")
|
||||
echo "Release version: $VERSION"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Download ripgrep binaries
|
||||
run: npm run download-ripgrep
|
||||
|
||||
- name: Clean previous builds
|
||||
run: rm -rf dist-standalone
|
||||
|
||||
- name: Generate Protos (First Pass)
|
||||
run: npm run protos && npm run protos-go
|
||||
|
||||
- name: Compile CLI
|
||||
run: npm run compile-cli
|
||||
|
||||
- name: Compile CLI for all platforms
|
||||
run: npm run compile-cli-all-platforms
|
||||
|
||||
- name: Build standalone NPM package
|
||||
env:
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
CLINE_ENVIRONMENT: production
|
||||
OTEL_TELEMETRY_ENABLED: "1"
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
POSTHOG_TELEMETRY_ENABLED: "true"
|
||||
run: npm run compile-standalone-npm
|
||||
|
||||
- name: Generate Protos (Second Pass - Bug Workaround)
|
||||
run: npm run protos && npm run protos-go
|
||||
|
||||
- name: Verify build output
|
||||
run: |
|
||||
echo "Checking dist-standalone directory..."
|
||||
ls -la dist-standalone/
|
||||
|
||||
echo "Verifying CLI binaries..."
|
||||
ls -lh cli/bin/cline-* || echo "Warning: CLI binaries not found"
|
||||
|
||||
echo "Checking package.json in dist-standalone..."
|
||||
cat dist-standalone/package.json | grep version
|
||||
|
||||
- name: Publish to NPM with latest tag
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_RELEASE_TOKEN }}
|
||||
run: |
|
||||
echo "Publishing version ${{ steps.version.outputs.version }} to NPM with tag 'latest'..."
|
||||
cd dist-standalone
|
||||
npm publish --tag latest --access public
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
echo "✅ Successfully published cline@${{ steps.version.outputs.version }} to NPM with tag 'latest'"
|
||||
echo ""
|
||||
echo "📦 Install with: npm install -g cline"
|
||||
echo "🔗 NPM: https://www.npmjs.com/package/cline/v/${{ steps.version.outputs.version }}"
|
||||
@@ -0,0 +1,175 @@
|
||||
name: Publish NPM Nightly
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 12 * * *' # 4 AM PST (UTC-8) = 12 UTC
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
checks: write # Required by test workflow
|
||||
pull-requests: write # Required by test workflow
|
||||
|
||||
jobs:
|
||||
test:
|
||||
uses: ./.github/workflows/test.yml
|
||||
|
||||
publish-npm-nightly:
|
||||
needs: test
|
||||
name: Publish Cline CLI (Nightly) to NPM
|
||||
if: github.repository == 'cline/cline' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check for recent commits
|
||||
id: check_commits
|
||||
run: |
|
||||
if [ $(git rev-list --count HEAD --since="24 hours ago") -eq 0 ]; then
|
||||
echo "No commits in last 24 hours, skipping publish"
|
||||
echo "skip=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "Found recent commits, proceeding with publish"
|
||||
echo "skip=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Setup Go
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.24'
|
||||
cache-dependency-path: cli/go.sum
|
||||
|
||||
# Cache root dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache root dependencies
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
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
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
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.check_commits.outputs.skip != 'true' && steps.root-cache.outputs.cache-hit != 'true'
|
||||
run: npm ci --include=optional
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
if: steps.check_commits.outputs.skip != 'true' && steps.webview-cache.outputs.cache-hit != 'true'
|
||||
run: cd webview-ui && npm ci --include=optional
|
||||
|
||||
- name: Generate nightly version with timestamp
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
id: version
|
||||
run: |
|
||||
# Read base version from cli/package.json (e.g., "1.0.9")
|
||||
BASE_VERSION=$(node -p "require('./cli/package.json').version")
|
||||
|
||||
# Generate timestamp (Unix epoch seconds)
|
||||
TIMESTAMP=$(date +%s)
|
||||
|
||||
# Create unique nightly version: 1.0.9-nightly.1736365200
|
||||
VERSION="${BASE_VERSION}-nightly.${TIMESTAMP}"
|
||||
|
||||
echo "Base version: $BASE_VERSION"
|
||||
echo "Generated nightly version: $VERSION"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Update cli/package.json with nightly version
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: |
|
||||
# Update version with timestamp-based nightly version
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const pkg = JSON.parse(fs.readFileSync('cli/package.json', 'utf8'));
|
||||
pkg.version = '${{ steps.version.outputs.version }}';
|
||||
fs.writeFileSync('cli/package.json', JSON.stringify(pkg, null, '\t'));
|
||||
"
|
||||
|
||||
echo "Using version ${{ steps.version.outputs.version }} for build"
|
||||
cat cli/package.json | grep '"version"'
|
||||
|
||||
- name: Download ripgrep binaries
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: npm run download-ripgrep
|
||||
|
||||
- name: Clean previous builds
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: rm -rf dist-standalone
|
||||
|
||||
- name: Generate Protos (First Pass)
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: npm run protos && npm run protos-go
|
||||
|
||||
- name: Compile CLI
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: npm run compile-cli
|
||||
|
||||
- name: Compile CLI for all platforms
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: npm run compile-cli-all-platforms
|
||||
|
||||
- name: Build standalone NPM package
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
env:
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
CLINE_ENVIRONMENT: production
|
||||
OTEL_TELEMETRY_ENABLED: "1"
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
POSTHOG_TELEMETRY_ENABLED: "true"
|
||||
run: npm run compile-standalone-npm
|
||||
|
||||
- name: Generate Protos (Second Pass - Bug Workaround)
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: npm run protos && npm run protos-go
|
||||
|
||||
- name: Verify build output
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: |
|
||||
echo "Checking dist-standalone directory..."
|
||||
ls -la dist-standalone/
|
||||
|
||||
echo "Verifying CLI binaries..."
|
||||
ls -lh cli/bin/cline-* || echo "Warning: CLI binaries not found"
|
||||
|
||||
echo "Checking package.json in dist-standalone..."
|
||||
cat dist-standalone/package.json | grep version
|
||||
|
||||
- name: Publish to NPM with nightly tag
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_RELEASE_TOKEN }}
|
||||
run: |
|
||||
echo "Publishing version ${{ steps.version.outputs.version }} to NPM with tag 'nightly'..."
|
||||
cd dist-standalone
|
||||
npm publish --tag nightly --access public
|
||||
|
||||
- name: Summary
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: |
|
||||
echo "✅ Successfully published cline@${{ steps.version.outputs.version }} to NPM with tag 'nightly'"
|
||||
echo ""
|
||||
echo "📦 Install with: npm install -g cline@nightly"
|
||||
echo "🔗 NPM: https://www.npmjs.com/package/cline/v/${{ steps.version.outputs.version }}"
|
||||
@@ -36,6 +36,8 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.tag }}
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
@@ -116,22 +118,31 @@ jobs:
|
||||
echo "Successfully published release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
|
||||
fi
|
||||
|
||||
# - name: Get Changelog Entry
|
||||
# id: changelog
|
||||
# uses: mindsers/changelog-reader-action@v2
|
||||
# with:
|
||||
# # This expects a standard Keep a Changelog format
|
||||
# # "latest" means it will read whichever is the most recent version
|
||||
# # set in "## [1.2.3] - 2025-01-28" style
|
||||
# version: latest
|
||||
- name: Get Previous Tag
|
||||
id: prev_tag
|
||||
run: |
|
||||
CURRENT_TAG="${{ steps.validate_tag.outputs.tag }}"
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 "$CURRENT_TAG^" 2>/dev/null || echo "")
|
||||
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get Changelog Entry
|
||||
id: changelog
|
||||
run: |
|
||||
# Get content between first ## [ and second ## [
|
||||
CONTENT=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md)
|
||||
echo "content<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ steps.validate_tag.outputs.tag }}
|
||||
files: "*.vsix"
|
||||
# body: ${{ steps.changelog.outputs.content }}
|
||||
generate_release_notes: true
|
||||
body: |
|
||||
${{ steps.changelog.outputs.content }}
|
||||
|
||||
**Full Changelog**: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.prev_tag }}...${{ steps.validate_tag.outputs.tag }}
|
||||
prerelease: ${{ github.event.inputs.release-type == 'pre-release' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
name: Trigger Jetbrains Plugin <-> Cline Tests
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened]
|
||||
types: [opened, reopened]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
concurrency:
|
||||
group: jetbrains-trigger-${{ github.event.number }}
|
||||
group: jetbrains-trigger-${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
trigger-integration-test:
|
||||
name: Run Tests
|
||||
runs-on: ubuntu-latest
|
||||
# Run on PR open/reopen, or when someone comments /test-jetbrains on a PR
|
||||
if: |
|
||||
github.event_name == 'pull_request_target' ||
|
||||
(github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
contains(github.event.comment.body, '/test-jetbrains'))
|
||||
steps:
|
||||
- name: Generate GitHub App Token
|
||||
id: app-token
|
||||
@@ -22,16 +31,28 @@ jobs:
|
||||
owner: cline
|
||||
repositories: intellij-plugin
|
||||
|
||||
- name: Get PR details (for issue_comment trigger)
|
||||
id: pr-details
|
||||
if: github.event_name == 'issue_comment'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
PR_DATA=$(gh api repos/${{ github.repository }}/pulls/${{ github.event.issue.number }})
|
||||
echo "head_ref=$(echo "$PR_DATA" | jq -r '.head.ref')" >> $GITHUB_OUTPUT
|
||||
echo "head_sha=$(echo "$PR_DATA" | jq -r '.head.sha')" >> $GITHUB_OUTPUT
|
||||
echo "title=$(echo "$PR_DATA" | jq -r '.title')" >> $GITHUB_OUTPUT
|
||||
echo "html_url=$(echo "$PR_DATA" | jq -r '.html_url')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Sanitize untrusted inputs
|
||||
id: sanitize
|
||||
env:
|
||||
RAW_BRANCH_NAME: ${{ github.head_ref }}
|
||||
RAW_PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
RAW_BRANCH_NAME: ${{ github.event_name == 'pull_request_target' && github.head_ref || steps.pr-details.outputs.head_ref }}
|
||||
RAW_PR_TITLE: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.title || steps.pr-details.outputs.title }}
|
||||
run: |
|
||||
# Sanitize branch name for JSON
|
||||
BRANCH_NAME_JSON=$(jq -n --arg b "$RAW_BRANCH_NAME" '$b')
|
||||
echo "branch_name=$BRANCH_NAME_JSON" >> $GITHUB_OUTPUT
|
||||
|
||||
|
||||
# Sanitize PR title for JSON
|
||||
PR_TITLE_JSON=$(jq -n --arg t "$RAW_PR_TITLE" '$t')
|
||||
echo "pr_title=$PR_TITLE_JSON" >> $GITHUB_OUTPUT
|
||||
@@ -40,6 +61,9 @@ jobs:
|
||||
env:
|
||||
BRANCH_NAME: ${{ steps.sanitize.outputs.branch_name }}
|
||||
PR_TITLE: ${{ steps.sanitize.outputs.pr_title }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
PR_SHA: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || steps.pr-details.outputs.head_sha }}
|
||||
PR_URL: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.html_url || steps.pr-details.outputs.html_url }}
|
||||
run: |
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer ${{ steps.app-token.outputs.token }}" \
|
||||
@@ -51,19 +75,23 @@ jobs:
|
||||
{
|
||||
"event_type": "cline-pr-check",
|
||||
"client_payload": {
|
||||
"pr_number": "${{ github.event.number }}",
|
||||
"pr_number": "$PR_NUMBER",
|
||||
"branch_name": $BRANCH_NAME,
|
||||
"action": "${{ github.event.action }}",
|
||||
"sha": "${{ github.event.pull_request.head.sha }}",
|
||||
"sha": "$PR_SHA",
|
||||
"pr_title": $PR_TITLE,
|
||||
"pr_url": "${{ github.event.pull_request.html_url }}"
|
||||
"pr_url": "$PR_URL"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
- name: Log trigger details
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
PR_SHA: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || steps.pr-details.outputs.head_sha }}
|
||||
run: |
|
||||
echo "Triggered IntelliJ Plugin integration test for:"
|
||||
echo " PR #${{ github.event.number }}"
|
||||
echo " PR #$PR_NUMBER"
|
||||
echo " Trigger: ${{ github.event_name }}"
|
||||
echo " Action: ${{ github.event.action }}"
|
||||
echo " SHA: ${{ github.event.pull_request.head.sha }}"
|
||||
echo " SHA: $PR_SHA"
|
||||
|
||||
+11
@@ -8,12 +8,14 @@ tmp
|
||||
|
||||
.DS_Store
|
||||
.idea
|
||||
.husky/_/
|
||||
|
||||
pnpm-lock.yaml
|
||||
|
||||
.clineignore
|
||||
.venv
|
||||
.actrc
|
||||
CLAUDE.local.md
|
||||
|
||||
webview-ui/src/**/*.js
|
||||
webview-ui/src/**/*.js.map
|
||||
@@ -27,6 +29,10 @@ coverage-unit
|
||||
|
||||
*evals.env
|
||||
.env
|
||||
.secrets
|
||||
.github/act/.secrets
|
||||
|
||||
.worktrees
|
||||
|
||||
## Generated files ##
|
||||
src/generated/
|
||||
@@ -35,3 +41,8 @@ webview-ui/src/services/grpc-client.ts
|
||||
|
||||
# E2E Tests
|
||||
test-results
|
||||
|
||||
/.github/act
|
||||
/pkg
|
||||
.secrets
|
||||
|
||||
|
||||
Vendored
+16
-4
@@ -12,7 +12,10 @@
|
||||
"args": [
|
||||
"--extensionDevelopmentPath=${workspaceFolder}",
|
||||
"--disable-workspace-trust",
|
||||
"--disable-extensions", // Avoid conflicts with installed extensions
|
||||
"--disable-extension",
|
||||
"saoudrizwan.claude-dev", // Avoid conflicts with installed Cline
|
||||
"--disable-extension",
|
||||
"saoudrizwan.cline-nightly", // Avoid conflicts with installed Cline Nightly
|
||||
"${workspaceFolder}"
|
||||
],
|
||||
"outFiles": [
|
||||
@@ -33,7 +36,10 @@
|
||||
"args": [
|
||||
"--extensionDevelopmentPath=${workspaceFolder}",
|
||||
"--disable-workspace-trust",
|
||||
"--disable-extensions", // Avoid conflicts with installed extensions
|
||||
"--disable-extension",
|
||||
"saoudrizwan.claude-dev", // Avoid conflicts with installed Cline
|
||||
"--disable-extension",
|
||||
"saoudrizwan.cline-nightly", // Avoid conflicts with installed Cline Nightly
|
||||
"${workspaceFolder}"
|
||||
],
|
||||
"outFiles": [
|
||||
@@ -54,7 +60,10 @@
|
||||
"args": [
|
||||
"--extensionDevelopmentPath=${workspaceFolder}",
|
||||
"--disable-workspace-trust",
|
||||
"--disable-extensions", // Avoid conflicts with installed extensions
|
||||
"--disable-extension",
|
||||
"saoudrizwan.claude-dev", // Avoid conflicts with installed Cline
|
||||
"--disable-extension",
|
||||
"saoudrizwan.cline-nightly", // Avoid conflicts with installed Cline Nightly
|
||||
"${workspaceFolder}"
|
||||
],
|
||||
"outFiles": [
|
||||
@@ -77,7 +86,10 @@
|
||||
"--user-data-dir=${workspaceFolder}/dist/tmp/user",
|
||||
"--profile-temp",
|
||||
"--sync=off",
|
||||
"--disable-extensions", // Avoid conflicts with installed extensions
|
||||
"--disable-extension",
|
||||
"saoudrizwan.claude-dev", // Avoid conflicts with installed Cline
|
||||
"--disable-extension",
|
||||
"saoudrizwan.cline-nightly", // Avoid conflicts with installed Cline Nightly
|
||||
"--extensionDevelopmentPath=${workspaceFolder}",
|
||||
"${workspaceFolder}"
|
||||
],
|
||||
|
||||
Vendored
+3
-1
@@ -27,5 +27,7 @@
|
||||
"source.removeUnused.biome": "always",
|
||||
"source.removeUnusedImports": "always",
|
||||
"source.organizeImports.biome": "always"
|
||||
}
|
||||
},
|
||||
// Disable auto-forwarding ports to prevent Simple Browser from opening the Vite dev server
|
||||
"remote.autoForwardPorts": false
|
||||
}
|
||||
|
||||
+2
-3
@@ -1,6 +1,8 @@
|
||||
# Default
|
||||
.vscode/**
|
||||
.vscode-test/**
|
||||
.worktrees/**
|
||||
CLAUDE.local.md
|
||||
out/
|
||||
dist-standalone/
|
||||
node_modules/
|
||||
@@ -40,9 +42,6 @@ buf.yaml
|
||||
.changeset/
|
||||
.clinerules/
|
||||
|
||||
# Include specific file needed for Background Exec mode
|
||||
!standalone/runtime-files/vscode/enhanced-terminal.js
|
||||
|
||||
# Ignore all webview-ui files except the build directory (https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/frameworks/hello-world-react-cra/.vscodeignore)
|
||||
webview-ui/src/**
|
||||
webview-ui/public/**
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
.gitignore
|
||||
+211
-1
@@ -1,5 +1,215 @@
|
||||
# Changelog
|
||||
|
||||
## [3.51.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Adding OpenAI gpt-5.2-codex model to the model picker
|
||||
|
||||
## [3.50.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Add gpt-5.2-codex OpenAI model support
|
||||
- Add create-pull-request skill
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix the selection of remotely configured providers
|
||||
- Fix act_mode_respond to prevent consecutive calls
|
||||
- Fix invalid tool call IDs when switching between model formats
|
||||
|
||||
## [3.49.1]
|
||||
|
||||
### Added
|
||||
|
||||
- Add telemetry to track usage of skills feature
|
||||
- Add version headers to Cline backend requests
|
||||
- Phase in Responses API usage instead of defaulting for every supported model
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix workflow slash command search to be case-insensitive
|
||||
- Fix model display in ModelPickerModal when using LiteLLM
|
||||
- Fix LiteLLM model fetching with default base URL
|
||||
- Fix crash when OpenAI-compatible APIs send usage chunks with empty or null choices arrays at end of streaming
|
||||
- Fix model ID for Kat Coder Pro Free model
|
||||
|
||||
## [3.49.0]
|
||||
|
||||
- Enable configuring an OTEL collector at runtime
|
||||
- Removing Minimax-2.1 from free model list as the free trial has ended
|
||||
- Improved image display in MCP responses
|
||||
- Auto-sync remote MCP servers from remote config to local settings
|
||||
|
||||
## [3.48.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Add Skills system for reusable, on-demand agent instructions
|
||||
- Add new websearch tooling in Cline provider
|
||||
- Add zai-glm-4.7 to Cerebras model list
|
||||
- Add model refresh and improve reasoning support for Vercel AI Gateway
|
||||
|
||||
### Fixed
|
||||
|
||||
- Revert #8341 due to regressions in diff view/document truncation (see #8423, #8429)
|
||||
- Fixed extension crash when using context menu selector
|
||||
|
||||
## [3.47.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Added experimental support for Background Edits (allows editing files in background without opening the diff view)
|
||||
- Updated free model to MiniMax M2.1 (replacing MiniMax M2)
|
||||
- Added support for Azure based identity authentication in OpenAI Compatible provider and Azure OpenAI
|
||||
- Add `supportsReasoning` property to Baseten models
|
||||
|
||||
### Fixed
|
||||
|
||||
- Prevent expired token usage in authenticated requests
|
||||
- Exclude binary files without extensions from diffs
|
||||
- Preserve file endings and trailing newlines
|
||||
- Fix Cerebras rate limiting
|
||||
- Fix Auto Compact for Claude Code provider
|
||||
- Make Workspace and Favorites history filters independent
|
||||
- Fix remote MCP server connection failures (404 response handling)
|
||||
- Disable native tool calling for Deepseek 3.2 speciale
|
||||
- Show notification instead of opening sidebar on update
|
||||
- Fix Baseten model selector
|
||||
|
||||
### Refactored
|
||||
|
||||
- Modify prompts for parallel tool usage in Claude and Gemini 3 models
|
||||
|
||||
## [3.46.1]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Remove GLM 4.6 from free models
|
||||
|
||||
## [3.46.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Added GLM 4.7 model
|
||||
- Enhanced background terminal execution with command tracking, log file output, zombie process prevention (10-minute timeout), and clickable log paths in UI
|
||||
- Apply Patch tool for GPT-5+ models (replacing current diff edit tools)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Duplicate error messages during streaming for Diff Edit tool when Parallel Tool Calling is not enabled
|
||||
- Banner carousel styling and dismiss functionality
|
||||
- Typos in Gemini system prompt overrides
|
||||
- Model picker favorites ordering, star toggle, and keyboard navigation for OpenRouter and Vercel AI Gateway providers
|
||||
- Fetch remote config values from the cache
|
||||
|
||||
### Refactored
|
||||
|
||||
- Anthropic handler to use metadata for reasoning support
|
||||
- Bedrock provider to use metadata for reasoning support
|
||||
|
||||
## [3.45.1]
|
||||
|
||||
- Fixed MCP settings race condition where toggling auto-approve or changing timeout settings would cause the UI to flash and revert
|
||||
|
||||
## [3.45.0]
|
||||
|
||||
- Added Gemini 3 Flash Preview model
|
||||
|
||||
## [3.44.2]
|
||||
|
||||
- Polished the model picker UI with checkmarks for selected models, tooltips on Plan/Act tabs, and consistent arrow pointers across all popup modals
|
||||
- Improved WhatsNew modal responsiveness and cleaned up redundant UI elements
|
||||
- Fixed GLM models outputting garbled text in thinking tags—reasoning is now properly disabled for these models
|
||||
|
||||
## [3.44.1]
|
||||
|
||||
- Fixed a critical bug where local MCP servers stopped connecting after v3.42.0—all user-configured stdio-based MCP servers should now work again
|
||||
- Fixed remotely configured API keys not being extracted correctly for enterprise users
|
||||
- Added support for dynamic tool instructions that adapt based on runtime context, laying groundwork for future context-aware features
|
||||
|
||||
## [3.44.0]
|
||||
|
||||
## Added
|
||||
|
||||
- Updating minor version to show a proper banner for the release
|
||||
|
||||
## [3.43.1]
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix GLM-4.6 Model reference id
|
||||
|
||||
## [3.43.0]
|
||||
|
||||
### Added
|
||||
|
||||
- GLM-4.6
|
||||
- kat-coder-pro
|
||||
- Add parsing of env variable patterns to the mcpconfig.json
|
||||
|
||||
### Fixed
|
||||
|
||||
- TLS Proxy support issues for VSCode
|
||||
- Add supportsReasoning flag to OpenAI reasoning models
|
||||
- Fix thinking not available for some models in the OpenAI provider
|
||||
- Fix invalid signature field issues when switching between Gemini and Anthropic providers
|
||||
- Extract OpenRouter model filtering into reusable utility and use it in different model pickers
|
||||
- Fix a11y for auto approve checkbox
|
||||
- Improve ModelPickerModal provider list layout
|
||||
|
||||
### Refactored
|
||||
|
||||
- Migrate WhatsNewModal to new shared dialogue component
|
||||
|
||||
## [3.42.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Expose `getAvailableSlashCommands` rpc endpoint to UI clients
|
||||
- Made slash command menu and context menu accessible and screenreader-friendly
|
||||
- Made expanding/collapsing UI components accessible
|
||||
|
||||
### Fixed
|
||||
|
||||
- Devstral OpenRouter model ID and routing issues
|
||||
- Incorrect pricing display for Devstral model in the extension
|
||||
|
||||
## [3.41.0]
|
||||
|
||||
### Added
|
||||
|
||||
- OpenAI GPT-5.2
|
||||
- Devstral-2512 (formerly stealth model "Microwave")
|
||||
- Improvements to chat modal model picker
|
||||
- Amazon Nova 2 Lite
|
||||
- DeepSeek 3.2 to native tool calling allow list
|
||||
- Responses API support for Codex models in OpenAI provider (requires native tool calling)
|
||||
- Xmas Special Santa Cline
|
||||
- Welcome screen UI enhancements
|
||||
|
||||
### Fixed
|
||||
|
||||
- Initial checkpoint commit now non-blocking for improved responsiveness in large repositories
|
||||
- Gemini Vertex models erroring when thinking parameters are not supported
|
||||
- Restrictive file permissions for secrets.json
|
||||
- Ollama streaming requests not aborting when task is cancelled
|
||||
|
||||
### Refactored
|
||||
|
||||
- OpenAI provider to centralize temperature configuration and include missing GPT-5 model settings
|
||||
- OpenAI native handler to use metadata for model capabilities
|
||||
- Vertex provider to use metadata for model capabilities
|
||||
|
||||
## [3.40.2]
|
||||
|
||||
- Fix logout on network errors during token refresh (e.g., opening laptop while offline)
|
||||
|
||||
## [3.40.1]
|
||||
|
||||
- Fix cost calculation display for Anthropic API requests
|
||||
|
||||
## [3.40.0]
|
||||
|
||||
- Fix highlighted text flashing when task header is collapsed
|
||||
@@ -1527,4 +1737,4 @@ Add Opus 4.1 through Claude Code
|
||||
|
||||
## [0.0.6]
|
||||
|
||||
- Initial release
|
||||
- Initial release
|
||||
@@ -1,125 +1,2 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file is the secret sauce for working effectively in this codebase. It captures tribal knowledge—the nuanced, non-obvious patterns that make the difference between a quick fix and hours of back-and-forth & human intervention.
|
||||
|
||||
**When to add to this file:**
|
||||
- User had to intervene, correct, or hand-hold
|
||||
- Multiple back-and-forth attempts were needed to get something working
|
||||
- You discovered something that required reading many files to understand
|
||||
- A change touched files you wouldn't have guessed
|
||||
- Something worked differently than you expected
|
||||
- User explicitly asks to "add this to CLAUDE.md"
|
||||
|
||||
**Proactively suggest additions** when any of the above happen—don't wait to be asked.
|
||||
|
||||
**What NOT to add:** Stuff you can figure out from reading a few files, obvious patterns, or standard practices. This file should be high-signal, not comprehensive.
|
||||
|
||||
## gRPC/Protobuf Communication
|
||||
The extension and webview communicate via gRPC-like protocol over VS Code message passing.
|
||||
|
||||
**Proto files live in `proto/`** (e.g., `proto/cline/task.proto`, `proto/cline/ui.proto`)
|
||||
- Each feature domain has its own `.proto` file
|
||||
- For simple data, use shared types in `proto/cline/common.proto` (`StringRequest`, `Empty`, `Int64Request`)
|
||||
- For complex data, define custom messages in the feature's `.proto` file
|
||||
- Naming: Services `PascalCaseService`, RPCs `camelCase`, Messages `PascalCase`
|
||||
- For streaming responses, use `stream` keyword (see `subscribeToAuthCallback` in `account.proto`)
|
||||
|
||||
**Run `npm run protos`** after any proto changes—generates types in:
|
||||
- `src/shared/proto/` - Shared type definitions
|
||||
- `src/generated/grpc-js/` - Service implementations
|
||||
- `src/generated/nice-grpc/` - Promise-based clients
|
||||
- `src/generated/hosts/` - Generated handlers
|
||||
|
||||
**Adding new enum values** (like a new `ClineSay` type) requires updating conversion mappings in `src/shared/proto-conversions/cline-message.ts`
|
||||
|
||||
**Adding new RPC methods** requires:
|
||||
- Handler in `src/core/controller/<domain>/`
|
||||
- Call from webview via generated client: `UiServiceClient.scrollToSettings(StringRequest.create({ value: "browser" }))`
|
||||
|
||||
**Example—the `explain-changes` feature touched:**
|
||||
- `proto/cline/task.proto` - Added `ExplainChangesRequest` message and `explainChanges` RPC
|
||||
- `proto/cline/ui.proto` - Added `GENERATE_EXPLANATION = 29` to `ClineSay` enum
|
||||
- `src/shared/ExtensionMessage.ts` - Added `ClineSayGenerateExplanation` type
|
||||
- `src/shared/proto-conversions/cline-message.ts` - Added mapping for new say type
|
||||
- `src/core/controller/task/explainChanges.ts` - Handler implementation
|
||||
- `webview-ui/src/components/chat/ChatRow.tsx` - UI rendering
|
||||
|
||||
## Adding Tools to System Prompt
|
||||
This is tricky—multiple prompt variants and configs. **Always search for existing similar tools first and follow their pattern.** Look at the full chain from prompt definition → variant configs → handler → UI before implementing.
|
||||
|
||||
1. **Add to `ClineDefaultTool` enum** in `src/shared/tools.ts`
|
||||
2. **Tool definition** in `src/core/prompts/system-prompt/tools/` (create file like `generate_explanation.ts`)
|
||||
- Define variants for each `ModelFamily` (generic, next-gen, xs, etc.)
|
||||
- Export variants array (e.g., `export const my_tool_variants = [GENERIC, NATIVE_NEXT_GEN, XS]`)
|
||||
- **Fallback behavior**: If a variant isn't defined for a model family, `ClineToolSet.getToolByNameWithFallback()` automatically falls back to GENERIC. So you only need to export `[GENERIC]` unless the tool needs model-specific behavior.
|
||||
3. **Register in `src/core/prompts/system-prompt/tools/init.ts`** - Import and spread into `allToolVariants`
|
||||
4. **Add to variant configs** - Each model family has its own config in `src/core/prompts/system-prompt/variants/*/config.ts`. Add your tool's enum to the `.tools()` list:
|
||||
- `generic/config.ts`, `next-gen/config.ts`, `gpt-5/config.ts`, `native-gpt-5/config.ts`, `native-gpt-5-1/config.ts`, `native-next-gen/config.ts`, `gemini-3/config.ts`, `glm/config.ts`, `hermes/config.ts`, `xs/config.ts`
|
||||
- **Important**: If you add to a variant's config, make sure the tool spec exports a variant for that ModelFamily (or relies on GENERIC fallback)
|
||||
5. **Create handler** in `src/core/task/tools/handlers/`
|
||||
6. **Wire up in `ToolExecutor.ts`** if needed for execution flow
|
||||
7. **Add to tool parsing** in `src/core/assistant-message/index.ts` if needed
|
||||
8. **If tool has UI feedback**: add `ClineSay` enum in proto, update `src/shared/ExtensionMessage.ts`, update `src/shared/proto-conversions/cline-message.ts`, update `webview-ui/src/components/chat/ChatRow.tsx`
|
||||
|
||||
## Modifying System Prompt
|
||||
**Read these first:** `src/core/prompts/system-prompt/README.md`, `tools/README.md`, `__tests__/README.md`
|
||||
|
||||
System prompt is modular: **components** (reusable sections) + **variants** (model-specific configs) + **templates** (with `{{PLACEHOLDER}}` resolution).
|
||||
|
||||
**Key directories:**
|
||||
- `components/` - Shared sections: `rules.ts`, `capabilities.ts`, `editing_files.ts`, etc.
|
||||
- `variants/` - Model-specific: `generic/`, `next-gen/`, `xs/`, `gpt-5/`, `gemini-3/`, `hermes/`, `glm/`, etc.
|
||||
- `templates/` - Template engine and placeholder definitions
|
||||
|
||||
**Variant tiers (ask user which to modify):**
|
||||
- **Next-gen** (Claude 4, GPT-5, Gemini 2.5): `next-gen/`, `native-next-gen/`, `native-gpt-5/`, `native-gpt-5-1/`, `gemini-3/`, `gpt-5/`
|
||||
- **Standard** (default fallback): `generic/`
|
||||
- **Local/small models**: `xs/`, `hermes/`, `glm/`
|
||||
|
||||
**How overrides work:** Variants can override components via `componentOverrides` in their `config.ts`, or provide a custom template in `template.ts` (e.g., `next-gen/template.ts` exports `rules_template`). If no override, the shared component from `components/` is used.
|
||||
|
||||
**Example: Adding a rule to RULES section**
|
||||
1. Check if variant overrides rules: look for `rules_template` in `variants/*/template.ts` or `componentOverrides.RULES` in `config.ts`
|
||||
2. If shared: modify `components/rules.ts`
|
||||
3. If overridden: modify that variant's template
|
||||
4. XS variant is special—has heavily condensed inline content in `template.ts`
|
||||
|
||||
**After any changes, regenerate snapshots:**
|
||||
```bash
|
||||
UPDATE_SNAPSHOTS=true npm run test:unit
|
||||
```
|
||||
Snapshots live in `__tests__/__snapshots__/`. Tests validate across model families and context variations (browser, MCP, focus chain).
|
||||
|
||||
## Modifying Default Slash Commands
|
||||
Three places need updates:
|
||||
- `src/core/slash-commands/index.ts` - Command definitions
|
||||
- `src/core/prompts/commands.ts` - System prompt integration
|
||||
- `webview-ui/src/utils/slash-commands.ts` - Webview autocomplete
|
||||
|
||||
## ChatRow Cancelled/Interrupted States
|
||||
When a ChatRow displays a loading/in-progress state (spinner), you must handle what happens when the task is cancelled. This is non-obvious because cancellation doesn't update the message content—you have to infer it from context.
|
||||
|
||||
**The pattern:**
|
||||
1. A message has a `status` field (e.g., `"generating"`, `"complete"`, `"error"`) stored in `message.text` as JSON
|
||||
2. When cancelled mid-operation, the status stays `"generating"` forever—no one updates it
|
||||
3. To detect cancellation, check TWO conditions:
|
||||
- `!isLast` — if this message is no longer the last message, something else happened after it (interrupted)
|
||||
- `lastModifiedMessage?.ask === "resume_task" || "resume_completed_task"` — task was just cancelled and is waiting to resume
|
||||
|
||||
**Example from `generate_explanation`:**
|
||||
```tsx
|
||||
const wasCancelled =
|
||||
explanationInfo.status === "generating" &&
|
||||
(!isLast ||
|
||||
lastModifiedMessage?.ask === "resume_task" ||
|
||||
lastModifiedMessage?.ask === "resume_completed_task")
|
||||
const isGenerating = explanationInfo.status === "generating" && !wasCancelled
|
||||
```
|
||||
|
||||
**Why both checks?**
|
||||
- `!isLast` catches: cancelled → resumed → did other stuff → this old message is stale
|
||||
- `lastModifiedMessage?.ask === "resume_task"` catches: just cancelled, hasn't resumed yet, this message is still technically "last"
|
||||
|
||||
**See also:** `BrowserSessionRow.tsx` uses similar pattern with `isLastApiReqInterrupted` and `isLastMessageResume`.
|
||||
|
||||
**Backend side:** When streaming is cancelled, clean up properly (close tabs, clear comments, etc.) by checking `taskState.abort` after the streaming function returns.
|
||||
@.clinerules/general.md
|
||||
@.clinerules/network.md
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extension": [
|
||||
"ts"
|
||||
],
|
||||
"spec": [
|
||||
"tests/unit/core/**/*.test.ts",
|
||||
"tests/unit/commands/version.test.ts"
|
||||
],
|
||||
"ignore": [
|
||||
"tests/unit/commands/auth/**",
|
||||
"tests/unit/commands/task/**"
|
||||
],
|
||||
"node-option": [
|
||||
"import=tsx",
|
||||
"experimental-specifier-resolution=node"
|
||||
],
|
||||
"timeout": 10000
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
# CLI Features Implementation Plan
|
||||
|
||||
Based on the cline.1.md man page, this document outlines the implementation plan for all CLI features. The Cline man page indicates that cline cli runs as a client-server architecture where **Cline Core** runs as a standalone service, but for the typescript version of the CLI, that won't be the case. The Typescript CLI will just import the necessary objects it needs from cline core src directly.
|
||||
|
||||
## Overview
|
||||
|
||||
The TypeScript CLI scaffold is complete. This plan covers implementing the full feature set from the man page, prioritized by dependency order and user value.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Core Infrastructure (Prerequisites)
|
||||
**Status: ✅ Completed (tests passing)**
|
||||
|
||||
### 1.1 Output Formatting System
|
||||
**Priority: High** | **Complexity: Medium**
|
||||
|
||||
Implement the `-F/--output-format` global option to support `rich`, `json`, and `plain` output formats.
|
||||
|
||||
**Files to create:**
|
||||
- `cli-ts/src/core/output/formatter.ts` - Base formatter interface
|
||||
- `cli-ts/src/core/output/rich-formatter.ts` - Rich terminal output with colors/styling
|
||||
- `cli-ts/src/core/output/json-formatter.ts` - JSON output for scripting
|
||||
- `cli-ts/src/core/output/plain-formatter.ts` - Plain text output
|
||||
|
||||
**Types:**
|
||||
```typescript
|
||||
interface OutputFormatter {
|
||||
message(msg: ClineMessage): void
|
||||
error(err: Error): void
|
||||
success(text: string): void
|
||||
table(data: Record<string, unknown>[]): void
|
||||
list(items: string[]): void
|
||||
}
|
||||
|
||||
interface ClineMessage {
|
||||
type: 'ask' | 'say'
|
||||
text: string
|
||||
ts: number // Unix epoch milliseconds
|
||||
reasoning?: string
|
||||
say?: string // say subtype
|
||||
ask?: string // ask subtype
|
||||
partial?: boolean
|
||||
images?: string[]
|
||||
files?: string[]
|
||||
lastCheckpointHash?: string
|
||||
isCheckpointCheckedOut?: boolean
|
||||
isOperationOutsideWorkspace?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
**Tests:**
|
||||
- JSON formatter outputs valid JSON per message
|
||||
- Rich formatter uses colors when TTY available
|
||||
- Plain formatter strips all formatting
|
||||
|
||||
---
|
||||
|
||||
### 1.2 Configuration System
|
||||
**Priority: High** | **Complexity: Medium**
|
||||
|
||||
Implement persistent configuration storage and the `cline config` command group.
|
||||
|
||||
**Commands:**
|
||||
- `cline config set <key> <value>`
|
||||
- `cline config get <key>`
|
||||
- `cline config list`
|
||||
|
||||
**Files to create:**
|
||||
- `cli-ts/src/core/config-storage.ts` - Persistent config storage (JSON file in ~/.cline)
|
||||
- `cli-ts/src/commands/config/index.ts` - Config command group
|
||||
- `cli-ts/src/commands/config/set.ts`
|
||||
- `cli-ts/src/commands/config/get.ts`
|
||||
- `cli-ts/src/commands/config/list.ts`
|
||||
|
||||
**Config storage location:** `~/.cline/config.json`
|
||||
|
||||
**Tests:**
|
||||
- Config persists across CLI invocations
|
||||
- Config values can be overridden
|
||||
- Invalid keys produce helpful errors
|
||||
|
||||
---
|
||||
|
||||
### 1.3 Instance Registry & Lifecycle (DEPRECATED -- DO NOT IMPLEMENT)
|
||||
**Priority: Deprecated** | **Complexity: High**
|
||||
|
||||
Implement the instance management system for tracking running Cline Core instances.
|
||||
|
||||
**Files to create:**
|
||||
- `cli-ts/src/core/instance-registry.ts` - Track running instances (SQLite or JSON)
|
||||
- `cli-ts/src/core/instance-client.ts` - gRPC client for communicating with Cline Core
|
||||
- `cli-ts/src/commands/instance/index.ts` - Instance command group
|
||||
- `cli-ts/src/commands/instance/new.ts`
|
||||
- `cli-ts/src/commands/instance/list.ts`
|
||||
- `cli-ts/src/commands/instance/default.ts`
|
||||
- `cli-ts/src/commands/instance/kill.ts`
|
||||
|
||||
**Commands:**
|
||||
- `cline instance new [--default]` / `cline i n`
|
||||
- `cline instance list` / `cline i l`
|
||||
- `cline instance default <address>` / `cline i d`
|
||||
- `cline instance kill <address> [--all]` / `cline i k`
|
||||
|
||||
**Architecture notes:**
|
||||
- The CLI spawns `cline-core` as a child process
|
||||
- Instances are tracked in `~/.cline/instances.json` with addresses and PIDs
|
||||
- Default instance is stored in config
|
||||
|
||||
**Tests:**
|
||||
- New instance spawns cline-core process
|
||||
- List shows all running instances
|
||||
- Kill terminates specific or all instances
|
||||
- Default instance is used when --address not specified
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Authentication
|
||||
**Status: ✅ Completed (tests passing)**
|
||||
|
||||
### 2.1 Auth Command
|
||||
**Priority: High** | **Complexity: Medium**
|
||||
|
||||
Implement provider authentication system.
|
||||
|
||||
**Commands:**
|
||||
- `cline auth [provider] [key]` / `cline a`
|
||||
|
||||
**Files to create:**
|
||||
- `cli-ts/src/commands/auth/index.ts` - Auth command with interactive wizard
|
||||
- `cli-ts/src/core/auth/providers.ts` - Provider definitions (Anthropic, OpenRouter, etc.)
|
||||
- `cli-ts/src/core/auth/wizard.ts` - Interactive provider selection
|
||||
- `cli-ts/src/core/auth/oauth.ts` - OAuth flow handler (for providers that support it)
|
||||
|
||||
**Behavior:**
|
||||
- No args: Launch interactive wizard
|
||||
- Provider only: Prompt for key or launch OAuth
|
||||
- Provider + key: Store key directly
|
||||
|
||||
**Storage:** Keys stored in `~/.cline/secrets.json` (with appropriate permissions)
|
||||
|
||||
**Tests:**
|
||||
- Interactive wizard presents provider choices
|
||||
- API keys are securely stored
|
||||
- Keys can be updated
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Task Management
|
||||
**Status: ✅ Completed (207 tests passing)**
|
||||
|
||||
### 3.1 Task Command Group Base
|
||||
**Priority: High** | **Complexity: Medium** | **Status: ✅ Complete**
|
||||
|
||||
Implement the task command infrastructure.
|
||||
|
||||
**Files created:**
|
||||
- `cli-ts/src/commands/task/index.ts` - Task command group
|
||||
- `cli-ts/src/core/task-client.ts` - Task storage and management
|
||||
- `cli-ts/src/types/task.ts` - Task-related types
|
||||
|
||||
**Commands:**
|
||||
- `cline task` / `cline t` - Display help
|
||||
|
||||
---
|
||||
|
||||
### 3.2 Task Creation & History
|
||||
**Priority: High** | **Complexity: Medium** | **Status: ✅ Complete**
|
||||
|
||||
**Commands:**
|
||||
- `cline task new <prompt> [options]` / `cline t n`
|
||||
- `cline task list` / `cline t l` / `cline t ls`
|
||||
- `cline task open <task-id>` / `cline t o`
|
||||
|
||||
**Files created:**
|
||||
- `cli-ts/src/commands/task/new.ts`
|
||||
- `cli-ts/src/commands/task/list.ts`
|
||||
- `cli-ts/src/commands/task/open.ts`
|
||||
|
||||
**Options for task new/open:**
|
||||
- `-s, --setting <key=value>` - Override settings (repeatable)
|
||||
- `-y, --yolo` / `--no-interactive` - Autonomous mode
|
||||
- `-m, --mode <mode>` - Starting mode (act/plan)
|
||||
- `-w, --workspace <path>` - Working directory (new only)
|
||||
|
||||
**Options for task list:**
|
||||
- `-n, --limit <number>` - Limit results (default: 20)
|
||||
- `-a, --all` - Show all tasks
|
||||
- `--status <status>` - Filter by status
|
||||
|
||||
**Tests (53 new tests):**
|
||||
- ✅ TaskStorage: create, get, update, delete, list, findByPartialId
|
||||
- ✅ task new: creates task, validates mode, parses settings
|
||||
- ✅ task list: shows history, respects limit, filters by status, JSON output
|
||||
- ✅ task open: finds by full/partial ID, overrides mode/settings, resumes paused tasks
|
||||
|
||||
---
|
||||
|
||||
### 3.3 Task Communication - Embedded Controller Architecture
|
||||
**Priority: High** | **Complexity: High**
|
||||
|
||||
**Architecture Decision: In-Process Embedded Controller**
|
||||
|
||||
The CLI chat REPL will embed the Cline Controller directly in the CLI process (not via gRPC). This approach:
|
||||
- Uses direct method calls instead of gRPC serialization
|
||||
- Reuses infrastructure from `src/standalone/cline-core.ts`
|
||||
- Shares state via `~/.cline/` with VSCode extension
|
||||
- Outputs to terminal instead of webview
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ CLI Process │
|
||||
│ │
|
||||
│ ┌────────────┐ ┌────────────┐ ┌──────────────────┐│
|
||||
│ │ CLI Chat │───>│ Controller │───>│ Task + AI API ││
|
||||
│ │ REPL │<───│ │<───│ ││
|
||||
│ └────────────┘ └────────────┘ └──────────────────┘│
|
||||
│ │ │ │
|
||||
│ v v │
|
||||
│ ┌────────────┐ ┌──────────────┐ │
|
||||
│ │ Terminal │ │ StateManager │ │
|
||||
│ │ Output │ │ (~/.cline/) │ │
|
||||
│ └────────────┘ └──────────────┘ │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Key Source Files to Understand:**
|
||||
- `src/core/controller/index.ts` - Controller class with initTask(), cancelTask(), postStateToWebview()
|
||||
- `src/standalone/cline-core.ts` - Shows how to run Controller outside VSCode
|
||||
- `src/standalone/vscode-context.ts` - initializeContext() creates mock ExtensionContext
|
||||
- `src/standalone/protobus-service.ts` - Shows Controller methods exposed via gRPC
|
||||
- `src/generated/hosts/standalone/protobus-server-setup.ts` - All available RPC methods
|
||||
|
||||
**Key Controller Methods for CLI:**
|
||||
- `controller.initTask(prompt)` - Start a new task with prompt
|
||||
- `controller.task?.handleWebviewAskResponse('messageResponse', userInput)` - Send user input
|
||||
- `controller.task?.messageStateHandler.getClineMessages()` - Get conversation messages
|
||||
- `controller.cancelTask()` - Cancel current task
|
||||
- `controller.getStateToPostToWebview()` - Get full state (clineMessages, etc.)
|
||||
|
||||
**Commands:**
|
||||
- `cline task chat` / `cline t c` - Interactive chat mode with embedded Controller
|
||||
- `cline task send [message] [options]` / `cline t s` - Send single message
|
||||
- `cline task view [--follow] [--follow-complete]` / `cline t v` - View/stream conversation
|
||||
|
||||
**Files to create/modify:**
|
||||
- `cli-ts/src/core/embedded-controller.ts` - Initialize Controller in CLI process
|
||||
- `cli-ts/src/core/cli-webview-adapter.ts` - Adapter that outputs to terminal instead of webview
|
||||
- `cli-ts/src/commands/task/chat.ts` - Updated to use embedded Controller
|
||||
- `cli-ts/src/commands/task/send.ts` - Updated to use embedded Controller
|
||||
- `cli-ts/src/commands/task/view.ts` - Updated to use embedded Controller
|
||||
|
||||
**Existing file to leverage:**
|
||||
- `cli-ts/src/core/host-provider-setup.ts` - Already sets up HostProvider for CLI
|
||||
|
||||
**Options for task send:**
|
||||
- `-a, --approve` - Approve proposed action
|
||||
- `-d, --deny` - Deny proposed action
|
||||
- `-f, --file <FILE>` - Attach file
|
||||
- `-y, --no-interactive, --yolo` - Autonomous mode
|
||||
- `-m, --mode <mode>` - Switch mode
|
||||
|
||||
**Options for task view:**
|
||||
- `-f, --follow` - Stream updates in real-time
|
||||
- `-c, --follow-complete` - Follow until completion
|
||||
|
||||
**Implementation Steps:**
|
||||
1. Create `embedded-controller.ts` to initialize Controller using:
|
||||
- `initializeContext()` from `src/standalone/vscode-context.ts`
|
||||
- `setupHostProvider()` from `cli-ts/src/core/host-provider-setup.ts`
|
||||
- Direct Controller import from `src/core/controller/index.ts`
|
||||
|
||||
2. Create `cli-webview-adapter.ts` to handle state updates:
|
||||
- Listen to `controller.task?.messageStateHandler` events
|
||||
- Format ClineMessages for terminal output
|
||||
- Handle streaming partial messages
|
||||
|
||||
3. Update `chat.ts` to use embedded Controller:
|
||||
- Initialize Controller on command start
|
||||
- Send prompts via `controller.initTask(prompt)`
|
||||
- Receive messages via state handler events
|
||||
- Handle user input via `handleWebviewAskResponse()`
|
||||
|
||||
4. Update `send.ts` and `view.ts` similarly
|
||||
|
||||
**Tests:**
|
||||
- Chat mode provides REPL interface with real Controller
|
||||
- Messages stream to terminal in real-time
|
||||
- Approve/deny call correct Controller methods
|
||||
- State persists to ~/.cline/ and is readable by VSCode
|
||||
|
||||
---
|
||||
|
||||
### 3.4 Task Control
|
||||
**Priority: Medium** | **Complexity: Medium**
|
||||
|
||||
**Commands:**
|
||||
- `cline task restore <checkpoint>` / `cline t r`
|
||||
- `cline task pause` / `cline t p`
|
||||
|
||||
**Files to create:**
|
||||
- `cli-ts/src/commands/task/restore.ts`
|
||||
- `cli-ts/src/commands/task/pause.ts`
|
||||
|
||||
**Tests:**
|
||||
- Restore reverts to checkpoint
|
||||
- Pause suspends execution
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Instant Task Mode
|
||||
|
||||
### 4.1 Instant Task Shorthand
|
||||
**Priority: High** | **Complexity: Medium**
|
||||
|
||||
Implement `cline "prompt"` instant task mode that combines instance + task + chat.
|
||||
|
||||
**Modify:**
|
||||
- `cli-ts/src/index.ts` - Detect prompt argument and route to instant task
|
||||
|
||||
**Options:**
|
||||
- `-o, --oneshot` - Complete and stop
|
||||
- `-s, --setting <key> <value>` - Override settings
|
||||
- `-y, --no-interactive, --yolo` - Autonomous mode
|
||||
- `-m, --mode <mode>` - Starting mode
|
||||
- `-w, --workspace <path>` - Additional workspace paths (can repeat)
|
||||
|
||||
**Behavior:**
|
||||
1. Get or spawn default instance
|
||||
2. Create new task with prompt
|
||||
3. Enter chat mode (or oneshot if -o)
|
||||
|
||||
**Tests:**
|
||||
- Instant task spawns instance if needed
|
||||
- Oneshot completes and exits
|
||||
- Workspace paths are passed correctly
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Global Options Enhancement
|
||||
|
||||
### 5.1 Address Flag
|
||||
**Priority: Medium** | **Complexity: Low**
|
||||
|
||||
Add `-a, --address <ADDR>` global option to specify which Cline Core instance to use.
|
||||
|
||||
**Modify:**
|
||||
- `cli-ts/src/index.ts` - Add --address option
|
||||
- All task commands to use address or default
|
||||
|
||||
---
|
||||
|
||||
### 5.2 Verbose Flag Enhancement
|
||||
**Priority: Low** | **Complexity: Low**
|
||||
|
||||
Enhance `-v, --verbose` to show debug output including gRPC communication details.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order (Recommended)
|
||||
|
||||
### Sprint 1: Foundation (Completed)
|
||||
1. [x] 1.1 Output Formatting System
|
||||
2. [x] 1.2 Configuration System
|
||||
3. [x] 2.1 Auth Command
|
||||
|
||||
### Sprint 2: Instance Management (Deprecated)
|
||||
4. [x] 1.3 Instance Registry & Lifecycle (Deprecated, skipped)
|
||||
|
||||
### Sprint 3: Task Basics (Completed)
|
||||
5. [x] 3.1 Task Command Group Base
|
||||
6. [x] 3.2 Task Creation & History
|
||||
|
||||
### Sprint 4: Task Communication (✅ Complete)
|
||||
7. [x] 3.3 Task Communication (chat, send, view) - Embedded Controller architecture implemented
|
||||
|
||||
### Sprint 5: Advanced Features
|
||||
8. [ ] 4.1 Instant Task Mode
|
||||
9. [ ] 3.4 Task Control
|
||||
10. [ ] 5.1 Address Flag
|
||||
11. [ ] 5.2 Verbose Flag Enhancement
|
||||
|
||||
---
|
||||
|
||||
## File Structure Summary
|
||||
|
||||
```
|
||||
cli-ts/
|
||||
├── src/
|
||||
│ ├── index.ts # Main entry (enhanced)
|
||||
│ ├── commands/
|
||||
│ │ ├── version.ts # ✓ Complete
|
||||
│ │ ├── auth/
|
||||
│ │ │ └── index.ts
|
||||
│ │ ├── config/
|
||||
│ │ │ ├── index.ts
|
||||
│ │ │ ├── set.ts
|
||||
│ │ │ ├── get.ts
|
||||
│ │ │ └── list.ts
|
||||
│ │ ├── instance/
|
||||
│ │ │ ├── index.ts
|
||||
│ │ │ ├── new.ts
|
||||
│ │ │ ├── list.ts
|
||||
│ │ │ ├── default.ts
|
||||
│ │ │ └── kill.ts
|
||||
│ │ └── task/
|
||||
│ │ ├── index.ts
|
||||
│ │ ├── new.ts
|
||||
│ │ ├── list.ts
|
||||
│ │ ├── open.ts
|
||||
│ │ ├── chat.ts
|
||||
│ │ ├── send.ts
|
||||
│ │ ├── view.ts
|
||||
│ │ ├── restore.ts
|
||||
│ │ └── pause.ts
|
||||
│ ├── core/
|
||||
│ │ ├── config.ts # ✓ Complete
|
||||
│ │ ├── logger.ts # ✓ Complete
|
||||
│ │ ├── context.ts # ✓ Complete
|
||||
│ │ ├── host-provider-setup.ts # ✓ Complete
|
||||
│ │ ├── config-storage.ts # NEW
|
||||
│ │ ├── instance-registry.ts # NEW
|
||||
│ │ ├── instance-client.ts # NEW
|
||||
│ │ ├── task-client.ts # NEW
|
||||
│ │ ├── output/
|
||||
│ │ │ ├── formatter.ts
|
||||
│ │ │ ├── rich-formatter.ts
|
||||
│ │ │ ├── json-formatter.ts
|
||||
│ │ │ └── plain-formatter.ts
|
||||
│ │ └── auth/
|
||||
│ │ ├── providers.ts
|
||||
│ │ ├── wizard.ts
|
||||
│ │ └── oauth.ts
|
||||
│ └── types/
|
||||
│ ├── config.ts # ✓ Complete
|
||||
│ ├── logger.ts # ✓ Complete
|
||||
│ ├── task.ts # NEW
|
||||
│ └── message.ts # NEW (ClineMessage)
|
||||
└── tests/
|
||||
└── unit/
|
||||
├── commands/
|
||||
│ └── version.test.ts # ✓ Complete
|
||||
├── core/
|
||||
│ ├── config.test.ts # ✓ Complete
|
||||
│ └── logger.test.ts # ✓ Complete
|
||||
└── ... (new tests for each module)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Start Sprint 3: Task Basics (Task Command Group Base + Task Creation & History)
|
||||
2. Follow with Sprint 4: Task Communication (chat/send/view)
|
||||
3. Finish with Sprint 5: Advanced Features (instant task mode, task control, address flag, verbose enhancement)
|
||||
|
||||
The plan is designed so each phase delivers working functionality that can be tested independently before moving to the next phase.
|
||||
|
||||
### New Task (Phase 3 Kickoff)
|
||||
**Objective:** Implement Task Command Group Base and Task Creation & History.
|
||||
|
||||
**Planned files to create/modify:**
|
||||
- `cli-ts/src/commands/task/index.ts`
|
||||
- `cli-ts/src/core/task-client.ts`
|
||||
- `cli-ts/src/types/task.ts`
|
||||
- `cli-ts/src/commands/task/new.ts`
|
||||
- `cli-ts/src/commands/task/list.ts`
|
||||
- `cli-ts/src/commands/task/open.ts`
|
||||
- Update `cli-ts/src/index.ts` to register the task command group
|
||||
|
||||
**Test requirements:**
|
||||
- New task creates task in instance
|
||||
- List shows task history with IDs and snippets
|
||||
- Open resumes task with saved settings
|
||||
@@ -0,0 +1,305 @@
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import * as esbuild from "esbuild"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const rootDir = path.resolve(__dirname, "..")
|
||||
|
||||
const production = process.argv.includes("--production")
|
||||
const watch = process.argv.includes("--watch")
|
||||
|
||||
/**
|
||||
* Alias resolver plugin - resolves path aliases from tsconfig.json
|
||||
* This is adapted from the root esbuild.mjs to work with the CLI's directory structure
|
||||
* @type {import('esbuild').Plugin}
|
||||
*/
|
||||
/**
|
||||
* Plugin to resolve 'vscode' imports to the standalone shim
|
||||
* The shim provides stub implementations for vscode APIs in standalone mode
|
||||
* @type {import('esbuild').Plugin}
|
||||
*/
|
||||
const vscodeShimPlugin = {
|
||||
name: "vscode-shim",
|
||||
setup(build) {
|
||||
const vscodeShimPath = path.resolve(rootDir, "standalone/runtime-files/vscode/index.js")
|
||||
|
||||
// Resolve 'vscode' to our virtual shim entry
|
||||
build.onResolve({ filter: /^vscode$/ }, () => {
|
||||
return {
|
||||
path: vscodeShimPath,
|
||||
// Use sideEffects: false to avoid issues with initialization order
|
||||
}
|
||||
})
|
||||
|
||||
// The vscode-stubs.js uses implicit global assignment (vscode = {})
|
||||
// which fails in strict mode. We need to load it without strict mode
|
||||
// by marking it and its dependencies as external
|
||||
build.onLoad({ filter: /vscode-stubs\.js$/ }, async (args) => {
|
||||
const contents = fs.readFileSync(args.path, "utf8")
|
||||
// Wrap the contents to declare vscode as a local variable
|
||||
return {
|
||||
contents: `var vscode;\n${contents}`,
|
||||
loader: "js",
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
const aliasResolverPlugin = {
|
||||
name: "alias-resolver",
|
||||
setup(build) {
|
||||
// Aliases point to the root src directory (parent of cli-ts)
|
||||
const aliases = {
|
||||
"@": path.resolve(rootDir, "src"),
|
||||
"@core": path.resolve(rootDir, "src/core"),
|
||||
"@integrations": path.resolve(rootDir, "src/integrations"),
|
||||
"@services": path.resolve(rootDir, "src/services"),
|
||||
"@shared": path.resolve(rootDir, "src/shared"),
|
||||
"@utils": path.resolve(rootDir, "src/utils"),
|
||||
"@packages": path.resolve(rootDir, "src/packages"),
|
||||
"@hosts": path.resolve(rootDir, "src/hosts"),
|
||||
"@generated": path.resolve(rootDir, "src/generated"),
|
||||
"@api": path.resolve(rootDir, "src/core/api"),
|
||||
// CLI-specific aliases
|
||||
"@cli": path.resolve(__dirname, "src"),
|
||||
}
|
||||
|
||||
// For each alias entry, create a resolver
|
||||
Object.entries(aliases).forEach(([alias, aliasPath]) => {
|
||||
const aliasRegex = new RegExp(`^${alias}($|/.*)`)
|
||||
build.onResolve({ filter: aliasRegex }, (args) => {
|
||||
const importPath = args.path.replace(alias, aliasPath)
|
||||
|
||||
// First, check if the path exists as is
|
||||
if (fs.existsSync(importPath)) {
|
||||
const stats = fs.statSync(importPath)
|
||||
if (stats.isDirectory()) {
|
||||
// If it's a directory, try to find index files
|
||||
const extensions = [".ts", ".tsx", ".js", ".jsx"]
|
||||
for (const ext of extensions) {
|
||||
const indexFile = path.join(importPath, `index${ext}`)
|
||||
if (fs.existsSync(indexFile)) {
|
||||
return { path: indexFile }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// It's a file that exists, so return it
|
||||
return { path: importPath }
|
||||
}
|
||||
}
|
||||
|
||||
// If the path doesn't exist, try appending extensions
|
||||
const extensions = [".ts", ".tsx", ".js", ".jsx"]
|
||||
for (const ext of extensions) {
|
||||
const pathWithExtension = `${importPath}${ext}`
|
||||
if (fs.existsSync(pathWithExtension)) {
|
||||
return { path: pathWithExtension }
|
||||
}
|
||||
}
|
||||
|
||||
// If nothing worked, return the original path and let esbuild handle the error
|
||||
return { path: importPath }
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Problem matcher plugin for watch mode
|
||||
*/
|
||||
const esbuildProblemMatcherPlugin = {
|
||||
name: "esbuild-problem-matcher",
|
||||
setup(build) {
|
||||
build.onStart(() => {
|
||||
console.log("[watch] build started")
|
||||
})
|
||||
build.onEnd((result) => {
|
||||
result.errors.forEach(({ text, location }) => {
|
||||
console.error(`✘ [ERROR] ${text}`)
|
||||
if (location) {
|
||||
console.error(` ${location.file}:${location.line}:${location.column}:`)
|
||||
}
|
||||
})
|
||||
console.log("[watch] build finished")
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin to copy tree-sitter WASM files to dist directory
|
||||
* These are required at runtime for code parsing functionality
|
||||
* @type {import('esbuild').Plugin}
|
||||
*/
|
||||
const copyWasmFiles = {
|
||||
name: "copy-wasm-files",
|
||||
setup(build) {
|
||||
build.onEnd(() => {
|
||||
const targetDir = path.resolve(__dirname, "dist")
|
||||
|
||||
// Copy tree-sitter.wasm from web-tree-sitter
|
||||
const treeSitterSource = path.join(rootDir, "node_modules", "web-tree-sitter", "tree-sitter.wasm")
|
||||
if (fs.existsSync(treeSitterSource)) {
|
||||
fs.copyFileSync(treeSitterSource, path.join(targetDir, "tree-sitter.wasm"))
|
||||
} else {
|
||||
console.warn("Warning: tree-sitter.wasm not found in node_modules/web-tree-sitter")
|
||||
}
|
||||
|
||||
// Copy language-specific WASM files from tree-sitter-wasms
|
||||
const languageWasmDir = path.join(rootDir, "node_modules", "tree-sitter-wasms", "out")
|
||||
const languages = [
|
||||
"typescript",
|
||||
"tsx",
|
||||
"python",
|
||||
"rust",
|
||||
"javascript",
|
||||
"go",
|
||||
"cpp",
|
||||
"c",
|
||||
"c_sharp",
|
||||
"ruby",
|
||||
"java",
|
||||
"php",
|
||||
"swift",
|
||||
"kotlin",
|
||||
]
|
||||
|
||||
if (fs.existsSync(languageWasmDir)) {
|
||||
languages.forEach((lang) => {
|
||||
const filename = `tree-sitter-${lang}.wasm`
|
||||
const sourcePath = path.join(languageWasmDir, filename)
|
||||
if (fs.existsSync(sourcePath)) {
|
||||
fs.copyFileSync(sourcePath, path.join(targetDir, filename))
|
||||
}
|
||||
})
|
||||
} else {
|
||||
console.warn("Warning: tree-sitter-wasms/out directory not found")
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// Read package.json for version injection
|
||||
const rootPackageJson = JSON.parse(fs.readFileSync(path.resolve(rootDir, "package.json"), "utf8"))
|
||||
|
||||
// Build environment variables
|
||||
const buildEnvVars = {
|
||||
"import.meta.url": "_importMetaUrl",
|
||||
"process.env.IS_STANDALONE": JSON.stringify("true"),
|
||||
// Inject the Cline version at build time to avoid runtime package.json loading
|
||||
__CLINE_VERSION__: JSON.stringify(rootPackageJson.version),
|
||||
}
|
||||
|
||||
if (production) {
|
||||
buildEnvVars["process.env.IS_DEV"] = "false"
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin to handle package.json requires by bundling them inline
|
||||
* This handles JSON files that may have broken relative paths after bundling
|
||||
* @type {import('esbuild').Plugin}
|
||||
*/
|
||||
const jsonResolverPlugin = {
|
||||
name: "json-resolver",
|
||||
setup(build) {
|
||||
// Handle requires to package.json files by resolving and loading them inline
|
||||
build.onResolve({ filter: /\.json$/ }, (args) => {
|
||||
// Only handle relative paths
|
||||
if (args.path.startsWith(".")) {
|
||||
const resolvedPath = path.resolve(args.resolveDir, args.path)
|
||||
if (fs.existsSync(resolvedPath)) {
|
||||
return {
|
||||
path: resolvedPath,
|
||||
namespace: "json-inline",
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
// Load JSON files and emit them as CommonJS modules with the JSON data
|
||||
build.onLoad({ filter: /.*/, namespace: "json-inline" }, (args) => {
|
||||
const contents = fs.readFileSync(args.path, "utf8")
|
||||
return {
|
||||
contents: `module.exports = ${contents}`,
|
||||
loader: "js",
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// CLI-specific configuration
|
||||
const cliConfig = {
|
||||
entryPoints: [path.resolve(__dirname, "src/index.ts")],
|
||||
outfile: path.resolve(__dirname, "dist/index.cjs"),
|
||||
bundle: true,
|
||||
minify: production,
|
||||
sourcemap: !production,
|
||||
logLevel: "silent",
|
||||
define: buildEnvVars,
|
||||
tsconfig: path.resolve(__dirname, "tsconfig.json"),
|
||||
plugins: [vscodeShimPlugin, jsonResolverPlugin, aliasResolverPlugin, copyWasmFiles, esbuildProblemMatcherPlugin],
|
||||
format: "cjs",
|
||||
sourcesContent: false,
|
||||
platform: "node",
|
||||
loader: {
|
||||
".json": "json", // Bundle JSON files inline
|
||||
},
|
||||
banner: {
|
||||
js: "const _importMetaUrl=require('url').pathToFileURL(__filename)",
|
||||
},
|
||||
// These modules need to load files from the module directory at runtime,
|
||||
// so they cannot be bundled. Note: vscode is handled by vscodeShimPlugin
|
||||
// @vscode/ripgrep provides platform-specific binaries that must be resolved at runtime
|
||||
external: ["@grpc/reflection", "grpc-health-check", "better-sqlite3", "@vscode/ripgrep"],
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy runtime files needed for standalone mode
|
||||
* The vscode-context.ts expects package.json at INSTALL_DIR/extension/package.json
|
||||
*/
|
||||
async function copyRuntimeFiles() {
|
||||
const extensionDir = path.resolve(__dirname, "dist/extension")
|
||||
|
||||
// Create extension directory
|
||||
if (!fs.existsSync(extensionDir)) {
|
||||
fs.mkdirSync(extensionDir, { recursive: true })
|
||||
}
|
||||
|
||||
// Copy package.json from standalone/runtime-files to dist/extension
|
||||
const sourcePackageJson = path.resolve(rootDir, "standalone/runtime-files/package.json")
|
||||
const destPackageJson = path.resolve(extensionDir, "package.json")
|
||||
|
||||
if (fs.existsSync(sourcePackageJson)) {
|
||||
fs.copyFileSync(sourcePackageJson, destPackageJson)
|
||||
} else {
|
||||
console.warn(`Warning: ${sourcePackageJson} not found, creating minimal package.json`)
|
||||
// Fallback: create a minimal package.json with version from root
|
||||
const minimalPackageJson = {
|
||||
name: "cline",
|
||||
version: rootPackageJson.version,
|
||||
displayName: "Cline",
|
||||
}
|
||||
fs.writeFileSync(destPackageJson, JSON.stringify(minimalPackageJson, null, 2))
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const ctx = await esbuild.context(cliConfig)
|
||||
if (watch) {
|
||||
await ctx.watch()
|
||||
await copyRuntimeFiles()
|
||||
console.log("Watching for changes...")
|
||||
} else {
|
||||
await ctx.rebuild()
|
||||
await copyRuntimeFiles()
|
||||
await ctx.dispose()
|
||||
console.log("Build completed successfully!")
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
Generated
+2840
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"version": "1.0.0",
|
||||
"description": "Cline CLI - Command-line interface for Cline AI assistant",
|
||||
"main": "dist/index.cjs",
|
||||
"bin": {
|
||||
"clt": "./dist/index.cjs"
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "node esbuild.mjs",
|
||||
"build:watch": "node esbuild.mjs --watch",
|
||||
"build:prod": "node esbuild.mjs --production",
|
||||
"start": "node dist/index.cjs",
|
||||
"dev": "node esbuild.mjs && node dist/index.cjs",
|
||||
"test": "mocha",
|
||||
"test:watch": "mocha --watch",
|
||||
"test:coverage": "c8 mocha"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vscode/ripgrep": "^1.15.9",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.1.0",
|
||||
"marked": "^15.0.12",
|
||||
"marked-terminal": "^7.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/chai": "^4.3.16",
|
||||
"@types/marked-terminal": "^6.1.1",
|
||||
"@types/mocha": "^10.0.7",
|
||||
"@types/node": "^20.14.10",
|
||||
"@types/sinon": "^17.0.3",
|
||||
"c8": "^10.1.2",
|
||||
"chai": "^4.4.1",
|
||||
"esbuild": "^0.27.0",
|
||||
"mocha": "^10.6.0",
|
||||
"sinon": "^17.0.1",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.16.2",
|
||||
"typescript": "^5.5.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* Config command group - manage persistent CLI configuration
|
||||
*
|
||||
* This command uses Cline's StateManager to read/write settings directly,
|
||||
* ensuring CLI config changes are reflected in the extension and vice versa.
|
||||
*/
|
||||
|
||||
import { Command } from "commander"
|
||||
import { disposeEmbeddedController, getEmbeddedController } from "../../core/embedded-controller.js"
|
||||
import type { OutputFormatter } from "../../core/output/types.js"
|
||||
import type { CliConfig } from "../../types/config.js"
|
||||
import type { Logger } from "../../types/logger.js"
|
||||
|
||||
/**
|
||||
* Parse a string value into the appropriate type based on the key
|
||||
*/
|
||||
export function parseValue(key: string, value: string): unknown {
|
||||
// Handle boolean values
|
||||
const lowerValue = value.toLowerCase()
|
||||
if (lowerValue === "true" || lowerValue === "1" || lowerValue === "yes") {
|
||||
return true
|
||||
}
|
||||
if (lowerValue === "false" || lowerValue === "0" || lowerValue === "no") {
|
||||
return false
|
||||
}
|
||||
|
||||
// Try to parse as JSON (for arrays and objects)
|
||||
const trimmed = value.trim()
|
||||
if ((trimmed.startsWith("[") && trimmed.endsWith("]")) || (trimmed.startsWith("{") && trimmed.endsWith("}"))) {
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
// If JSON parsing fails, fall through to other parsing
|
||||
}
|
||||
}
|
||||
|
||||
// Handle numeric values - try to parse as number
|
||||
const numValue = Number(value)
|
||||
if (!Number.isNaN(numValue) && value.trim() !== "") {
|
||||
return numValue
|
||||
}
|
||||
|
||||
// Default: return as string
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a nested value from an object using dot notation
|
||||
* e.g., getNestedValue(obj, "browserSettings.viewport.width")
|
||||
*/
|
||||
export function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
||||
const parts = path.split(".")
|
||||
let current: unknown = obj
|
||||
|
||||
for (const part of parts) {
|
||||
if (current === null || current === undefined || typeof current !== "object") {
|
||||
return undefined
|
||||
}
|
||||
current = (current as Record<string, unknown>)[part]
|
||||
}
|
||||
|
||||
return current
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a nested value in an object using dot notation
|
||||
* e.g., setNestedValue(obj, "browserSettings.viewport.width", 1200)
|
||||
* Returns the modified root object for the top-level key
|
||||
*/
|
||||
export function setNestedValue(
|
||||
obj: Record<string, unknown>,
|
||||
path: string,
|
||||
value: unknown,
|
||||
): { rootKey: string; rootValue: unknown } {
|
||||
const parts = path.split(".")
|
||||
const rootKey = parts[0]
|
||||
|
||||
if (parts.length === 1) {
|
||||
// Simple case: top-level key
|
||||
return { rootKey, rootValue: value }
|
||||
}
|
||||
|
||||
// Clone the root object to avoid mutating the original
|
||||
const rootValue = JSON.parse(JSON.stringify(obj[rootKey] ?? {}))
|
||||
|
||||
// Navigate to the parent of the target, creating objects as needed
|
||||
let current = rootValue as Record<string, unknown>
|
||||
for (let i = 1; i < parts.length - 1; i++) {
|
||||
const part = parts[i]
|
||||
if (current[part] === undefined || current[part] === null || typeof current[part] !== "object") {
|
||||
current[part] = {}
|
||||
}
|
||||
current = current[part] as Record<string, unknown>
|
||||
}
|
||||
|
||||
// Set the final value
|
||||
current[parts[parts.length - 1]] = value
|
||||
return { rootKey, rootValue }
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the config set command
|
||||
*/
|
||||
function createConfigSetCommand(config: CliConfig, logger: Logger, formatter: OutputFormatter): Command {
|
||||
return new Command("set")
|
||||
.description("Set a configuration value (supports dot notation for nested values, e.g., browserSettings.viewport.width)")
|
||||
.argument("<key>", "Configuration key to set")
|
||||
.argument("<value>", "Value to set")
|
||||
.action(async (key: string, value: string) => {
|
||||
logger.debug(`Setting config: ${key} = ${value}`)
|
||||
|
||||
try {
|
||||
// Initialize embedded controller to access StateManager
|
||||
const controller = await getEmbeddedController(logger, config.configDir)
|
||||
|
||||
// Parse value to appropriate type
|
||||
const parsedValue = parseValue(key, value)
|
||||
|
||||
// Check if this is a nested path
|
||||
if (key.includes(".")) {
|
||||
// For nested paths, get the current root object, modify it, and save the whole thing
|
||||
const rootKey = key.split(".")[0]
|
||||
let rootValue = controller.stateManager.getGlobalSettingsKey(rootKey as any)
|
||||
if (rootValue === undefined) {
|
||||
rootValue = controller.stateManager.getGlobalStateKey(rootKey as any)
|
||||
}
|
||||
|
||||
// Build the updated root object
|
||||
const currentRoot = rootValue !== undefined && typeof rootValue === "object" ? rootValue : {}
|
||||
const { rootValue: newRootValue } = setNestedValue({ [rootKey]: currentRoot }, key, parsedValue)
|
||||
|
||||
// Save the updated root object
|
||||
controller.stateManager.setGlobalState(rootKey as any, newRootValue as any)
|
||||
} else {
|
||||
// Simple top-level key
|
||||
controller.stateManager.setGlobalState(key as any, parsedValue as any)
|
||||
}
|
||||
|
||||
// Flush pending state to ensure changes are persisted before exit
|
||||
await controller.stateManager.flushPendingState()
|
||||
|
||||
formatter.success(`Set ${key} = ${String(parsedValue)}`)
|
||||
|
||||
// Cleanup and exit
|
||||
await disposeEmbeddedController(logger)
|
||||
process.exit(0)
|
||||
} catch (err) {
|
||||
formatter.error(err as Error)
|
||||
await disposeEmbeddedController(logger)
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the config get command
|
||||
*/
|
||||
function createConfigGetCommand(config: CliConfig, logger: Logger, formatter: OutputFormatter): Command {
|
||||
return new Command("get")
|
||||
.description("Get a configuration value (supports dot notation for nested values, e.g., browserSettings.viewport.width)")
|
||||
.argument("<key>", "Configuration key to get")
|
||||
.action(async (key: string) => {
|
||||
logger.debug(`Getting config: ${key}`)
|
||||
|
||||
try {
|
||||
// Initialize embedded controller to access StateManager
|
||||
const controller = await getEmbeddedController(logger, config.configDir)
|
||||
|
||||
let value: unknown
|
||||
|
||||
// Check if this is a nested path
|
||||
if (key.includes(".")) {
|
||||
// For nested paths, get the root object first
|
||||
const rootKey = key.split(".")[0]
|
||||
let rootValue = controller.stateManager.getGlobalSettingsKey(rootKey as any)
|
||||
if (rootValue === undefined) {
|
||||
rootValue = controller.stateManager.getGlobalStateKey(rootKey as any)
|
||||
}
|
||||
|
||||
if (rootValue !== undefined && typeof rootValue === "object") {
|
||||
// Get the nested value
|
||||
value = getNestedValue({ [rootKey]: rootValue }, key)
|
||||
}
|
||||
} else {
|
||||
// Simple top-level key
|
||||
value = controller.stateManager.getGlobalSettingsKey(key as any)
|
||||
if (value === undefined) {
|
||||
value = controller.stateManager.getGlobalStateKey(key as any)
|
||||
}
|
||||
}
|
||||
|
||||
if (value === undefined) {
|
||||
formatter.info(`${key} is not set`)
|
||||
} else {
|
||||
// Format objects/arrays as JSON for display
|
||||
const displayValue = typeof value === "object" ? JSON.stringify(value, null, 2) : value
|
||||
formatter.keyValue({ [key]: displayValue })
|
||||
}
|
||||
|
||||
// Cleanup and exit
|
||||
await disposeEmbeddedController(logger)
|
||||
process.exit(0)
|
||||
} catch (err) {
|
||||
formatter.error(err as Error)
|
||||
await disposeEmbeddedController(logger)
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the config list command
|
||||
*/
|
||||
function createConfigListCommand(config: CliConfig, logger: Logger, formatter: OutputFormatter): Command {
|
||||
return new Command("list").description("List all configuration values").action(async () => {
|
||||
logger.debug("Listing all config")
|
||||
|
||||
try {
|
||||
// Read the globalState.json file directly to get all settings
|
||||
const fs = await import("fs")
|
||||
const path = await import("path")
|
||||
const globalStatePath = path.join(config.configDir || `${process.env.HOME}/.cline`, "data", "globalState.json")
|
||||
|
||||
let allSettings: Record<string, unknown> = {}
|
||||
if (fs.existsSync(globalStatePath)) {
|
||||
const content = fs.readFileSync(globalStatePath, "utf-8")
|
||||
allSettings = JSON.parse(content)
|
||||
}
|
||||
|
||||
formatter.raw("")
|
||||
formatter.raw(JSON.stringify(allSettings, null, 2))
|
||||
formatter.raw("")
|
||||
|
||||
// Cleanup and exit
|
||||
process.exit(0)
|
||||
} catch (err) {
|
||||
formatter.error(err as Error)
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the config delete command
|
||||
*/
|
||||
function createConfigDeleteCommand(config: CliConfig, logger: Logger, formatter: OutputFormatter): Command {
|
||||
return new Command("delete")
|
||||
.alias("rm")
|
||||
.description("Delete a configuration value (reset to default)")
|
||||
.argument("<key>", "Configuration key to delete")
|
||||
.action(async (key: string) => {
|
||||
logger.debug(`Deleting config: ${key}`)
|
||||
|
||||
try {
|
||||
// Initialize embedded controller to access StateManager
|
||||
const controller = await getEmbeddedController(logger, config.configDir)
|
||||
|
||||
// Set the value to undefined to reset to default
|
||||
// Using type assertion since key is dynamic
|
||||
controller.stateManager.setGlobalState(key as any, undefined)
|
||||
|
||||
// Flush pending state to ensure changes are persisted before exit
|
||||
await controller.stateManager.flushPendingState()
|
||||
|
||||
formatter.success(`Reset ${key} to default`)
|
||||
|
||||
// Cleanup and exit
|
||||
await disposeEmbeddedController(logger)
|
||||
process.exit(0)
|
||||
} catch (err) {
|
||||
formatter.error(err as Error)
|
||||
await disposeEmbeddedController(logger)
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the config command group
|
||||
*/
|
||||
export function createConfigCommand(config: CliConfig, logger: Logger, formatter: OutputFormatter): Command {
|
||||
const configCommand = new Command("config").alias("c").description("Manage CLI configuration")
|
||||
|
||||
configCommand.addCommand(createConfigSetCommand(config, logger, formatter))
|
||||
configCommand.addCommand(createConfigGetCommand(config, logger, formatter))
|
||||
configCommand.addCommand(createConfigListCommand(config, logger, formatter))
|
||||
configCommand.addCommand(createConfigDeleteCommand(config, logger, formatter))
|
||||
|
||||
return configCommand
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Task chat command - interactive REPL mode with embedded Controller
|
||||
*
|
||||
* This command provides an interactive chat interface using Cline's
|
||||
* embedded Controller, allowing real-time AI interactions directly
|
||||
* from the terminal.
|
||||
*/
|
||||
|
||||
import { Command } from "commander"
|
||||
import { disposeEmbeddedController, getEmbeddedController } from "../../../core/embedded-controller.js"
|
||||
import type { OutputFormatter } from "../../../core/output/types.js"
|
||||
import { parseAtPaths, processExplicitFiles, processExplicitImages } from "../../../core/path-parser.js"
|
||||
import type { CliConfig } from "../../../types/config.js"
|
||||
import type { Logger } from "../../../types/logger.js"
|
||||
import { startRepl } from "./repl.js"
|
||||
import { createSession } from "./session.js"
|
||||
|
||||
/**
|
||||
* Collect multiple option values into an array
|
||||
* Used for -f and -i options that can be specified multiple times
|
||||
*/
|
||||
function collectOption(value: string, previous: string[]): string[] {
|
||||
return previous.concat([value])
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the task chat command
|
||||
*/
|
||||
export function createTaskChatCommand(config: CliConfig, logger: Logger, formatter: OutputFormatter): Command {
|
||||
const chatCommand = new Command("chat")
|
||||
.alias("c")
|
||||
.description("Interactive chat mode with embedded Cline Controller")
|
||||
.argument("[prompt]", "Initial prompt to start a new task (optional)")
|
||||
.option("-m, --mode <mode>", "Start in specific mode: act or plan")
|
||||
.option("-t, --task <id>", "Resume an existing task by ID")
|
||||
.option("-f, --file <path>", "Attach file to initial prompt (can be repeated)", collectOption, [])
|
||||
.option("-i, --image <path>", "Attach image to initial prompt (can be repeated)", collectOption, [])
|
||||
.option("-y, --yolo", "Enable autonomous mode (no confirmations)", false)
|
||||
.action(async (promptArg: string | undefined, options) => {
|
||||
logger.debug("Task chat command called", { promptArg, options })
|
||||
|
||||
try {
|
||||
// Process explicit file and image attachments from CLI options
|
||||
const cwd = process.cwd()
|
||||
let initialFiles: string[] = []
|
||||
let initialImages: string[] = []
|
||||
|
||||
// Process -f/--file options (can be files or images, auto-detected)
|
||||
if (options.file && options.file.length > 0) {
|
||||
const processed = processExplicitFiles(options.file, cwd)
|
||||
initialFiles = processed.files
|
||||
initialImages = processed.images
|
||||
}
|
||||
|
||||
// Process -i/--image options (must be images)
|
||||
if (options.image && options.image.length > 0) {
|
||||
const images = processExplicitImages(options.image, cwd)
|
||||
initialImages = initialImages.concat(images)
|
||||
}
|
||||
|
||||
// Parse @path references from the initial prompt if provided
|
||||
let processedPrompt = promptArg
|
||||
if (promptArg) {
|
||||
const parsed = parseAtPaths(promptArg, cwd)
|
||||
|
||||
// Show warnings for any files that couldn't be processed
|
||||
for (const warning of parsed.warnings) {
|
||||
formatter.warn(warning)
|
||||
}
|
||||
|
||||
processedPrompt = parsed.cleanedMessage
|
||||
initialFiles = initialFiles.concat(parsed.files)
|
||||
initialImages = initialImages.concat(parsed.images)
|
||||
}
|
||||
|
||||
// Initialize embedded controller
|
||||
const controller = await getEmbeddedController(logger, config.configDir)
|
||||
|
||||
// Set up mode if specified
|
||||
if (options.mode) {
|
||||
if (options.mode !== "plan" && options.mode !== "act") {
|
||||
throw new Error(`Invalid mode: "${options.mode}". Valid options are: act, plan`)
|
||||
}
|
||||
await controller.togglePlanActMode(options.mode as "plan" | "act")
|
||||
}
|
||||
|
||||
if (options.yolo) {
|
||||
controller.stateManager.setGlobalState("yoloModeToggled", true)
|
||||
// Increase mistake limit for autonomous operation (matches Go CLI behavior)
|
||||
controller.stateManager.setGlobalState("maxConsecutiveMistakes", 6)
|
||||
// Ensure we're in Act mode for autonomous execution (unless user explicitly chose Plan mode)
|
||||
if (!options.mode) {
|
||||
await controller.togglePlanActMode("act")
|
||||
}
|
||||
}
|
||||
|
||||
// Create chat session with yolo mode if specified
|
||||
const session = createSession(options.yolo)
|
||||
|
||||
if (options.yolo) {
|
||||
formatter.info("[YOLO] Autonomous mode enabled - no confirmations required")
|
||||
}
|
||||
|
||||
// Start the REPL
|
||||
await startRepl({
|
||||
session,
|
||||
controller,
|
||||
formatter,
|
||||
logger,
|
||||
config,
|
||||
initialPrompt: processedPrompt,
|
||||
initialImages: initialImages.length > 0 ? initialImages : undefined,
|
||||
initialFiles: initialFiles.length > 0 ? initialFiles : undefined,
|
||||
resumeTaskId: options.task,
|
||||
})
|
||||
} catch (error) {
|
||||
formatter.error((error as Error).message)
|
||||
await disposeEmbeddedController(logger)
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
|
||||
return chatCommand
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Tab completion for @ file/folder mentions in chat REPL
|
||||
*
|
||||
* Provides file and folder path completion when users type @ followed by a partial path.
|
||||
*/
|
||||
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
|
||||
/**
|
||||
* Result of finding an @ mention to complete
|
||||
*/
|
||||
interface AtMentionMatch {
|
||||
/** The text before the @ mention (to preserve in completion) */
|
||||
prefix: string
|
||||
/** The partial path after @ that needs completion */
|
||||
partial: string
|
||||
/** Character index where the @ starts */
|
||||
atIndex: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the @ mention being completed in the input line
|
||||
*
|
||||
* Handles multiple @ mentions by finding the last one that appears
|
||||
* to be incomplete (user is still typing it).
|
||||
*/
|
||||
function findAtMentionToComplete(line: string): AtMentionMatch | null {
|
||||
// Find the last @ that could be a file mention
|
||||
// We look for @ that's either at start or preceded by whitespace
|
||||
let atIndex = -1
|
||||
for (let i = line.length - 1; i >= 0; i--) {
|
||||
if (line[i] === "@") {
|
||||
// Check if it's at start or preceded by whitespace
|
||||
if (i === 0 || /\s/.test(line[i - 1])) {
|
||||
atIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (atIndex === -1) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Extract the partial path after @
|
||||
const afterAt = line.slice(atIndex + 1)
|
||||
|
||||
// If there's whitespace after @, this mention is complete, not being typed
|
||||
if (/\s/.test(afterAt)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
prefix: line.slice(0, atIndex),
|
||||
partial: afterAt,
|
||||
atIndex,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get completions for a partial file/folder path
|
||||
*/
|
||||
function getPathCompletions(partial: string, cwd: string): string[] {
|
||||
try {
|
||||
// Determine the directory to search and the prefix to match
|
||||
let searchDir: string
|
||||
let namePrefix: string
|
||||
|
||||
if (partial === "") {
|
||||
// Empty partial - list cwd contents
|
||||
searchDir = cwd
|
||||
namePrefix = ""
|
||||
} else if (partial.endsWith("/")) {
|
||||
// Ends with / - list that directory's contents
|
||||
searchDir = path.resolve(cwd, partial)
|
||||
namePrefix = ""
|
||||
} else {
|
||||
// Partial filename - list parent directory and filter
|
||||
const partialPath = path.resolve(cwd, partial)
|
||||
searchDir = path.dirname(partialPath)
|
||||
namePrefix = path.basename(partial)
|
||||
}
|
||||
|
||||
// Check if directory exists
|
||||
if (!fs.existsSync(searchDir) || !fs.statSync(searchDir).isDirectory()) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Read directory contents
|
||||
const entries = fs.readdirSync(searchDir, { withFileTypes: true })
|
||||
|
||||
// Filter and map entries
|
||||
const completions: string[] = []
|
||||
for (const entry of entries) {
|
||||
// Skip hidden files unless explicitly searching for them
|
||||
if (entry.name.startsWith(".") && !namePrefix.startsWith(".")) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if name matches prefix
|
||||
if (!entry.name.toLowerCase().startsWith(namePrefix.toLowerCase())) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Build the completion path
|
||||
let completionPath: string
|
||||
if (partial === "") {
|
||||
completionPath = entry.name
|
||||
} else if (partial.endsWith("/")) {
|
||||
completionPath = partial + entry.name
|
||||
} else {
|
||||
// Replace the partial filename with the full name
|
||||
const dirPart = partial.slice(0, partial.length - namePrefix.length)
|
||||
completionPath = dirPart + entry.name
|
||||
}
|
||||
|
||||
// Append / for directories
|
||||
if (entry.isDirectory()) {
|
||||
completionPath += "/"
|
||||
}
|
||||
|
||||
completions.push(completionPath)
|
||||
}
|
||||
|
||||
// Sort: directories first, then alphabetically
|
||||
completions.sort((a, b) => {
|
||||
const aIsDir = a.endsWith("/")
|
||||
const bIsDir = b.endsWith("/")
|
||||
if (aIsDir && !bIsDir) return -1
|
||||
if (!aIsDir && bIsDir) return 1
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
|
||||
return completions
|
||||
} catch {
|
||||
// If anything goes wrong, return no completions
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for creating a completer
|
||||
*/
|
||||
export interface CompleterOptions {
|
||||
/** The current working directory for path resolution */
|
||||
cwd: string
|
||||
/** Callback invoked when Tab is pressed on an empty line */
|
||||
onEmptyTab?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a readline completer function for @ file mentions
|
||||
*
|
||||
* Also supports triggering a callback when Tab is pressed on an empty line,
|
||||
* which is used for mode toggling.
|
||||
*
|
||||
* @param options - Completer options including cwd and callbacks
|
||||
* @returns A completer function compatible with readline
|
||||
*/
|
||||
export function createCompleter(options: CompleterOptions): (line: string) => [string[], string] {
|
||||
const { cwd, onEmptyTab } = options
|
||||
|
||||
return (line: string): [string[], string] => {
|
||||
// Check for empty input - trigger mode toggle callback if provided
|
||||
if (line === "" && onEmptyTab) {
|
||||
onEmptyTab()
|
||||
return [[], line]
|
||||
}
|
||||
|
||||
const match = findAtMentionToComplete(line)
|
||||
|
||||
if (!match) {
|
||||
// No @ mention being typed - no completions
|
||||
return [[], line]
|
||||
}
|
||||
|
||||
const pathCompletions = getPathCompletions(match.partial, cwd)
|
||||
|
||||
if (pathCompletions.length === 0) {
|
||||
return [[], line]
|
||||
}
|
||||
|
||||
// Build full line completions (prefix + @ + completed path)
|
||||
const fullCompletions = pathCompletions.map((p) => `${match.prefix}@${p}`)
|
||||
|
||||
// The "substring" is what readline uses to determine what to replace
|
||||
// We want to replace from the @ onwards
|
||||
const substring = `@${match.partial}`
|
||||
|
||||
// Return format: [completions, substring being completed]
|
||||
// If there's only one completion, readline will auto-complete
|
||||
// If multiple, it will show them as options
|
||||
return [fullCompletions, line]
|
||||
}
|
||||
}
|
||||
|
||||
// Export for testing
|
||||
export { findAtMentionToComplete, getPathCompletions }
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Chat command module
|
||||
*
|
||||
* Re-exports the main command factory for backward compatibility.
|
||||
*/
|
||||
|
||||
export { createTaskChatCommand } from "./command.js"
|
||||
|
||||
// Also export utilities for testing
|
||||
export { checkForPendingInput, isCompletionState, isFailureState, type PendingInputState } from "./input-checker.js"
|
||||
export { getModelIdForProvider, getModelIdKey } from "./model-utils.js"
|
||||
export { buildPromptString } from "./prompt.js"
|
||||
export { type ChatSession, createSession } from "./session.js"
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Input state checker for chat REPL
|
||||
*
|
||||
* Analyzes message history to determine if user input is needed.
|
||||
*/
|
||||
|
||||
import type { ClineMessage, ClineSayTool } from "@shared/ExtensionMessage"
|
||||
|
||||
/**
|
||||
* Auto-approval action keys that can be enabled for "don't ask again" functionality
|
||||
*/
|
||||
export type AutoApprovalAction = "readFiles" | "editFiles" | "executeAllCommands" | "useBrowser" | "useMcp"
|
||||
|
||||
/**
|
||||
* Result of checking for pending input
|
||||
*/
|
||||
export interface PendingInputState {
|
||||
awaitingApproval: boolean
|
||||
awaitingInput: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the last message requires user input
|
||||
*/
|
||||
export function checkForPendingInput(messages: ClineMessage[]): PendingInputState {
|
||||
if (messages.length === 0) {
|
||||
return { awaitingApproval: false, awaitingInput: false }
|
||||
}
|
||||
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
|
||||
// Skip partial messages
|
||||
if (lastMessage.partial) {
|
||||
return { awaitingApproval: false, awaitingInput: false }
|
||||
}
|
||||
|
||||
// Check if this is an "ask" type message
|
||||
if (lastMessage.type === "ask") {
|
||||
const ask = lastMessage.ask
|
||||
|
||||
// These require approval (yes/no response)
|
||||
const approvalAsks = ["command", "tool", "browser_action_launch", "use_mcp_server"]
|
||||
|
||||
// These require free-form input
|
||||
const inputAsks = ["followup", "plan_mode_respond", "act_mode_respond"]
|
||||
|
||||
if (approvalAsks.includes(ask || "")) {
|
||||
return { awaitingApproval: true, awaitingInput: false }
|
||||
}
|
||||
|
||||
if (inputAsks.includes(ask || "")) {
|
||||
return { awaitingApproval: false, awaitingInput: true }
|
||||
}
|
||||
|
||||
// Special cases
|
||||
if (ask === "api_req_failed") {
|
||||
return { awaitingApproval: true, awaitingInput: false }
|
||||
}
|
||||
|
||||
if (ask === "completion_result" || ask === "resume_task" || ask === "resume_completed_task") {
|
||||
return { awaitingApproval: false, awaitingInput: true }
|
||||
}
|
||||
}
|
||||
|
||||
return { awaitingApproval: false, awaitingInput: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the last message indicates a failure state (for yolo mode)
|
||||
*/
|
||||
export function isFailureState(messages: ClineMessage[]): { isFailure: boolean; actionKey: string | null } {
|
||||
if (messages.length === 0) {
|
||||
return { isFailure: false, actionKey: null }
|
||||
}
|
||||
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
|
||||
// Skip partial messages
|
||||
if (lastMessage.partial) {
|
||||
return { isFailure: false, actionKey: null }
|
||||
}
|
||||
|
||||
// Check for failure indicators
|
||||
if (
|
||||
lastMessage.ask === "api_req_failed" ||
|
||||
lastMessage.ask === "mistake_limit_reached" ||
|
||||
lastMessage.say === "error" ||
|
||||
lastMessage.say === "diff_error"
|
||||
) {
|
||||
// Use the message text as the action key for tracking consecutive failures
|
||||
const actionKey = lastMessage.text || lastMessage.ask || lastMessage.say || "unknown"
|
||||
return { isFailure: true, actionKey }
|
||||
}
|
||||
|
||||
return { isFailure: false, actionKey: null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the last message indicates task completion (for yolo mode)
|
||||
*/
|
||||
export function isCompletionState(messages: ClineMessage[]): boolean {
|
||||
if (messages.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
|
||||
// Skip partial messages
|
||||
if (lastMessage.partial) {
|
||||
return false
|
||||
}
|
||||
|
||||
return lastMessage.ask === "completion_result" || lastMessage.say === "completion_result"
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine which auto-approval action to enable based on the ask message
|
||||
*
|
||||
* @param msg - The pending ask message
|
||||
* @returns The auto-approval action key, or null if not applicable
|
||||
*/
|
||||
export function determineAutoApprovalAction(msg: ClineMessage): AutoApprovalAction | null {
|
||||
const ask = msg.ask
|
||||
|
||||
switch (ask) {
|
||||
case "tool": {
|
||||
// Parse tool message to determine if it's a read or edit operation
|
||||
if (!msg.text) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const tool = JSON.parse(msg.text) as ClineSayTool
|
||||
switch (tool.tool) {
|
||||
case "readFile":
|
||||
case "listFilesTopLevel":
|
||||
case "listFilesRecursive":
|
||||
case "listCodeDefinitionNames":
|
||||
case "searchFiles":
|
||||
case "webFetch":
|
||||
case "webSearch":
|
||||
return "readFiles"
|
||||
case "editedExistingFile":
|
||||
case "newFileCreated":
|
||||
return "editFiles"
|
||||
case "fileDeleted":
|
||||
// File deletion uses editFiles permission
|
||||
return "editFiles"
|
||||
default:
|
||||
return null
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
case "command":
|
||||
return "executeAllCommands"
|
||||
|
||||
case "browser_action_launch":
|
||||
return "useBrowser"
|
||||
|
||||
case "use_mcp_server":
|
||||
return "useMcp"
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Model ID utilities for chat command
|
||||
*
|
||||
* Functions to map providers to their corresponding model ID configuration keys.
|
||||
*/
|
||||
|
||||
import type { ApiConfiguration, ApiProvider } from "@shared/api"
|
||||
import type { Mode } from "@shared/storage/types"
|
||||
|
||||
/**
|
||||
* Get the model ID for the current provider and mode
|
||||
*/
|
||||
export function getModelIdForProvider(
|
||||
apiConfiguration: ApiConfiguration | undefined,
|
||||
provider: ApiProvider | undefined,
|
||||
mode: Mode,
|
||||
): string | undefined {
|
||||
if (!apiConfiguration || !provider) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const prefix = mode === "plan" ? "planMode" : "actMode"
|
||||
|
||||
// Map provider to the corresponding model ID field
|
||||
switch (provider) {
|
||||
case "openrouter":
|
||||
case "cline":
|
||||
return apiConfiguration[`${prefix}OpenRouterModelId`]
|
||||
case "anthropic":
|
||||
case "claude-code":
|
||||
case "bedrock":
|
||||
case "vertex":
|
||||
case "gemini":
|
||||
case "openai-native":
|
||||
case "deepseek":
|
||||
case "qwen":
|
||||
case "qwen-code":
|
||||
case "doubao":
|
||||
case "mistral":
|
||||
case "asksage":
|
||||
case "xai":
|
||||
case "moonshot":
|
||||
case "nebius":
|
||||
case "sambanova":
|
||||
case "cerebras":
|
||||
case "sapaicore":
|
||||
case "zai":
|
||||
case "fireworks":
|
||||
case "minimax":
|
||||
return apiConfiguration[`${prefix}ApiModelId`]
|
||||
case "openai":
|
||||
return apiConfiguration[`${prefix}OpenAiModelId`]
|
||||
case "ollama":
|
||||
return apiConfiguration[`${prefix}OllamaModelId`]
|
||||
case "lmstudio":
|
||||
return apiConfiguration[`${prefix}LmStudioModelId`]
|
||||
case "requesty":
|
||||
return apiConfiguration[`${prefix}RequestyModelId`]
|
||||
case "together":
|
||||
return apiConfiguration[`${prefix}TogetherModelId`]
|
||||
case "litellm":
|
||||
return apiConfiguration[`${prefix}LiteLlmModelId`]
|
||||
case "groq":
|
||||
return apiConfiguration[`${prefix}GroqModelId`]
|
||||
case "baseten":
|
||||
return apiConfiguration[`${prefix}BasetenModelId`]
|
||||
case "huggingface":
|
||||
return apiConfiguration[`${prefix}HuggingFaceModelId`]
|
||||
case "huawei-cloud-maas":
|
||||
return apiConfiguration[`${prefix}HuaweiCloudMaasModelId`]
|
||||
case "oca":
|
||||
return apiConfiguration[`${prefix}OcaModelId`]
|
||||
case "hicap":
|
||||
return apiConfiguration[`${prefix}HicapModelId`]
|
||||
case "aihubmix":
|
||||
return apiConfiguration[`${prefix}AihubmixModelId`]
|
||||
case "nousResearch":
|
||||
return apiConfiguration[`${prefix}NousResearchModelId`]
|
||||
case "vercel-ai-gateway":
|
||||
return apiConfiguration[`${prefix}VercelAiGatewayModelId`]
|
||||
case "vscode-lm":
|
||||
case "dify":
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the model ID state key for a given provider and mode
|
||||
* Some providers use provider-specific model ID keys (e.g., openRouterModelId),
|
||||
* while others use the generic apiModelId
|
||||
*/
|
||||
export function getModelIdKey(provider: string | undefined, mode: Mode): string {
|
||||
const modePrefix = mode === "plan" ? "planMode" : "actMode"
|
||||
|
||||
switch (provider) {
|
||||
case "openrouter":
|
||||
case "cline":
|
||||
return `${modePrefix}OpenRouterModelId`
|
||||
case "openai":
|
||||
return `${modePrefix}OpenAiModelId`
|
||||
case "ollama":
|
||||
return `${modePrefix}OllamaModelId`
|
||||
case "lmstudio":
|
||||
return `${modePrefix}LmStudioModelId`
|
||||
case "litellm":
|
||||
return `${modePrefix}LiteLlmModelId`
|
||||
case "requesty":
|
||||
return `${modePrefix}RequestyModelId`
|
||||
case "together":
|
||||
return `${modePrefix}TogetherModelId`
|
||||
case "fireworks":
|
||||
return `${modePrefix}FireworksModelId`
|
||||
case "groq":
|
||||
return `${modePrefix}GroqModelId`
|
||||
case "baseten":
|
||||
return `${modePrefix}BasetenModelId`
|
||||
case "huggingface":
|
||||
return `${modePrefix}HuggingFaceModelId`
|
||||
case "huawei-cloud-maas":
|
||||
return `${modePrefix}HuaweiCloudMaasModelId`
|
||||
case "oca":
|
||||
return `${modePrefix}OcaModelId`
|
||||
case "hicap":
|
||||
return `${modePrefix}HicapModelId`
|
||||
case "aihubmix":
|
||||
return `${modePrefix}AihubmixModelId`
|
||||
case "nousResearch":
|
||||
return `${modePrefix}NousResearchModelId`
|
||||
case "vercel-ai-gateway":
|
||||
return `${modePrefix}VercelAiGatewayModelId`
|
||||
default:
|
||||
return `${modePrefix}ApiModelId`
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Prompt string builder for chat REPL
|
||||
*
|
||||
* Builds the CLI prompt that shows current mode, provider, and model.
|
||||
*/
|
||||
|
||||
import type { ApiProvider } from "@shared/api"
|
||||
import type { Mode } from "@shared/storage/types"
|
||||
import chalk from "chalk"
|
||||
|
||||
/**
|
||||
* Build the CLI prompt string with mode, provider, and model
|
||||
* Format: [mode] provider/model >
|
||||
*/
|
||||
export function buildPromptString(mode: Mode, provider: ApiProvider | undefined, modelId: string | undefined): string {
|
||||
const modeStr = mode === "plan" ? chalk.yellow("[plan]") : chalk.cyan("[act]")
|
||||
const providerStr = provider || "unknown"
|
||||
|
||||
// Shorten very long model IDs for display (keep last part after last /)
|
||||
let modelStr = modelId || "unknown"
|
||||
if (modelStr.length > 40) {
|
||||
const lastSlash = modelStr.lastIndexOf("/")
|
||||
if (lastSlash > 0 && lastSlash < modelStr.length - 1) {
|
||||
modelStr = "..." + modelStr.substring(lastSlash)
|
||||
} else {
|
||||
modelStr = modelStr.substring(0, 37) + "..."
|
||||
}
|
||||
}
|
||||
|
||||
const providerModelStr = chalk.dim(`${providerStr}/${modelStr}`)
|
||||
|
||||
return `${modeStr} ${providerModelStr} ${chalk.white(">")} `
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
/**
|
||||
* REPL (Read-Eval-Print Loop) for chat command
|
||||
*
|
||||
* Handles readline setup, event handling, and the main interaction loop.
|
||||
*/
|
||||
|
||||
import type { ApiProvider } from "@shared/api"
|
||||
import type { Mode } from "@shared/storage/types"
|
||||
import readline from "readline"
|
||||
import type { Controller } from "@/core/controller"
|
||||
import { CliWebviewAdapter } from "../../../core/cli-webview-adapter.js"
|
||||
import { disposeEmbeddedController } from "../../../core/embedded-controller.js"
|
||||
import type { OutputFormatter } from "../../../core/output/types.js"
|
||||
import { parseAtPaths } from "../../../core/path-parser.js"
|
||||
import type { CliConfig } from "../../../types/config.js"
|
||||
import type { Logger } from "../../../types/logger.js"
|
||||
import { createCompleter } from "./completer.js"
|
||||
import { checkForPendingInput, determineAutoApprovalAction, isCompletionState, isFailureState } from "./input-checker.js"
|
||||
import { getModelIdForProvider } from "./model-utils.js"
|
||||
import { buildPromptString } from "./prompt.js"
|
||||
import type { ChatSession } from "./session.js"
|
||||
import { processSlashCommand } from "./slash-commands/index.js"
|
||||
|
||||
/** Yolo mode timeout: 5 minutes in milliseconds */
|
||||
const YOLO_TIMEOUT_MS = 5 * 60 * 1000
|
||||
|
||||
/** Yolo mode max consecutive failures before abort */
|
||||
const YOLO_MAX_FAILURES = 3
|
||||
|
||||
/**
|
||||
* Options for starting the REPL
|
||||
*/
|
||||
export interface ReplOptions {
|
||||
session: ChatSession
|
||||
controller: Controller
|
||||
formatter: OutputFormatter
|
||||
logger: Logger
|
||||
config: CliConfig
|
||||
initialPrompt?: string
|
||||
initialImages?: string[]
|
||||
initialFiles?: string[]
|
||||
resumeTaskId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the interactive REPL loop
|
||||
*/
|
||||
export async function startRepl(options: ReplOptions): Promise<void> {
|
||||
const { session, controller, formatter, logger, config, initialPrompt, initialImages, initialFiles, resumeTaskId } = options
|
||||
|
||||
// Create webview adapter for output
|
||||
session.adapter = new CliWebviewAdapter(controller, formatter)
|
||||
|
||||
// Track if we started with a prompt (AI will be processing)
|
||||
let startedWithPrompt = false
|
||||
|
||||
// Start or resume task
|
||||
if (resumeTaskId) {
|
||||
// Resume existing task
|
||||
const history = await controller.getTaskWithId(resumeTaskId)
|
||||
if (!history) {
|
||||
throw new Error(`Task not found: ${resumeTaskId}`)
|
||||
}
|
||||
session.taskId = await controller.initTask(undefined, undefined, undefined, history.historyItem)
|
||||
formatter.info(`Resumed task: ${session.taskId}`)
|
||||
} else if (initialPrompt) {
|
||||
// Start new task with prompt and any initial attachments
|
||||
startedWithPrompt = true
|
||||
|
||||
// Log attachment info
|
||||
if (initialFiles && initialFiles.length > 0) {
|
||||
formatter.info(`Attaching ${initialFiles.length} file(s)`)
|
||||
}
|
||||
if (initialImages && initialImages.length > 0) {
|
||||
formatter.info(`Attaching ${initialImages.length} image(s)`)
|
||||
}
|
||||
|
||||
session.taskId = await controller.initTask(
|
||||
initialPrompt,
|
||||
initialImages && initialImages.length > 0 ? initialImages : undefined,
|
||||
initialFiles && initialFiles.length > 0 ? initialFiles : undefined,
|
||||
)
|
||||
formatter.info(`Started task: ${session.taskId}`)
|
||||
// Enable spinner since AI will be processing
|
||||
session.adapter?.setProcessing(true)
|
||||
}
|
||||
|
||||
// Display welcome message
|
||||
displayWelcome(formatter, session, controller)
|
||||
|
||||
// Output existing messages if resuming
|
||||
if (session.taskId && session.adapter) {
|
||||
session.adapter.outputAllMessages()
|
||||
}
|
||||
|
||||
// Helper to toggle between act and plan mode
|
||||
const toggleMode = async (): Promise<void> => {
|
||||
// Only toggle when awaiting user input (not while AI is processing)
|
||||
if (isProcessing) {
|
||||
return
|
||||
}
|
||||
const state = await controller.getStateToPostToWebview()
|
||||
const currentMode = (state.mode || "act") as Mode
|
||||
const newMode = currentMode === "act" ? "plan" : "act"
|
||||
await controller.togglePlanActMode(newMode)
|
||||
await updatePromptString()
|
||||
showPrompt()
|
||||
}
|
||||
|
||||
// Create readline interface with @ file completion and mode toggle on empty Tab
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
prompt: "> ", // Default prompt, will be updated dynamically
|
||||
completer: createCompleter({
|
||||
cwd: process.cwd(),
|
||||
onEmptyTab: () => {
|
||||
// Use setImmediate to allow async operation outside completer
|
||||
setImmediate(() => toggleMode())
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
// Track if we're currently processing (AI is working)
|
||||
let isProcessing = startedWithPrompt
|
||||
// Track previous awaiting states to detect transitions
|
||||
let wasAwaitingInput = false
|
||||
|
||||
// Helper to set processing state and update spinner
|
||||
function setProcessingState(processing: boolean): void {
|
||||
isProcessing = processing
|
||||
session.adapter?.setProcessing(processing)
|
||||
}
|
||||
|
||||
// Helper to update the prompt string (but not necessarily show it)
|
||||
async function updatePromptString(): Promise<void> {
|
||||
const currentState = await controller.getStateToPostToWebview()
|
||||
const mode = (currentState.mode || "act") as Mode
|
||||
const provider = (
|
||||
mode === "plan"
|
||||
? currentState.apiConfiguration?.planModeApiProvider
|
||||
: currentState.apiConfiguration?.actModeApiProvider
|
||||
) as ApiProvider | undefined
|
||||
const modelId = getModelIdForProvider(currentState.apiConfiguration, provider, mode)
|
||||
const promptStr = buildPromptString(mode, provider, modelId)
|
||||
rl.setPrompt(promptStr)
|
||||
}
|
||||
|
||||
// Helper to show the prompt (call after updating)
|
||||
function showPrompt(): void {
|
||||
rl.prompt()
|
||||
}
|
||||
|
||||
// Start listening for state updates
|
||||
session.adapter.startListening((messages) => {
|
||||
const pendingState = checkForPendingInput(messages)
|
||||
session.awaitingApproval = pendingState.awaitingApproval
|
||||
session.awaitingInput = pendingState.awaitingInput
|
||||
|
||||
// Store the pending ask message for auto-approval determination
|
||||
if (pendingState.awaitingApproval && messages.length > 0) {
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
if (lastMessage.type === "ask" && !lastMessage.partial) {
|
||||
session.pendingAskMessage = lastMessage
|
||||
}
|
||||
} else if (!pendingState.awaitingApproval) {
|
||||
session.pendingAskMessage = null
|
||||
}
|
||||
|
||||
// YOLO MODE: Auto-respond to pending inputs
|
||||
if (session.yoloMode && controller.task && !session.yoloCompleted) {
|
||||
// Check for task completion first
|
||||
if (isCompletionState(messages)) {
|
||||
// Guard against processing completion multiple times
|
||||
session.yoloCompleted = true
|
||||
formatter.success("\n[YOLO] Task completed!")
|
||||
// Respond to the completion_result ask to unblock the handler
|
||||
const task = controller.task
|
||||
task.handleWebviewAskResponse("yesButtonClicked")
|
||||
// Schedule exit after brief delay to let response process
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await task.abortTask()
|
||||
} catch {
|
||||
// Task may already be cleaned up, ignore
|
||||
}
|
||||
// Exit successfully - task has completed
|
||||
process.exit(0)
|
||||
}, 200)
|
||||
return
|
||||
}
|
||||
|
||||
// Check for timeout (5 minutes)
|
||||
if (session.yoloActionStartTime && Date.now() - session.yoloActionStartTime > YOLO_TIMEOUT_MS) {
|
||||
formatter.error("\n[YOLO] Action timed out after 5 minutes. Aborting.")
|
||||
session.isRunning = false
|
||||
rl.close()
|
||||
process.exit(0)
|
||||
return
|
||||
}
|
||||
|
||||
// Check for failure state
|
||||
const failureCheck = isFailureState(messages)
|
||||
if (failureCheck.isFailure) {
|
||||
if (session.yoloLastFailedAction === failureCheck.actionKey) {
|
||||
session.yoloFailureCount++
|
||||
} else {
|
||||
session.yoloLastFailedAction = failureCheck.actionKey
|
||||
session.yoloFailureCount = 1
|
||||
}
|
||||
|
||||
if (session.yoloFailureCount >= YOLO_MAX_FAILURES) {
|
||||
formatter.error(`\n[YOLO] Same action failed ${YOLO_MAX_FAILURES} times. Aborting.`)
|
||||
session.isRunning = false
|
||||
rl.close()
|
||||
process.exit(0)
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-retry: approve the retry
|
||||
formatter.warn(`[YOLO] Action failed (attempt ${session.yoloFailureCount}/${YOLO_MAX_FAILURES}), retrying...`)
|
||||
session.yoloActionStartTime = Date.now()
|
||||
setProcessingState(true)
|
||||
wasAwaitingInput = false
|
||||
controller.task.handleWebviewAskResponse("yesButtonClicked")
|
||||
return
|
||||
} else {
|
||||
// Reset failure tracking on success
|
||||
session.yoloFailureCount = 0
|
||||
session.yoloLastFailedAction = null
|
||||
}
|
||||
|
||||
// Auto-approve pending approvals
|
||||
if (pendingState.awaitingApproval) {
|
||||
logger.debug("[YOLO] Auto-approving action")
|
||||
session.yoloActionStartTime = Date.now()
|
||||
setProcessingState(true)
|
||||
wasAwaitingInput = false
|
||||
controller.task.handleWebviewAskResponse("yesButtonClicked")
|
||||
session.awaitingApproval = false
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-respond to input requests with "proceed"
|
||||
if (pendingState.awaitingInput) {
|
||||
logger.debug("[YOLO] Auto-responding with 'proceed'")
|
||||
session.yoloActionStartTime = Date.now()
|
||||
setProcessingState(true)
|
||||
wasAwaitingInput = false
|
||||
controller.task.handleWebviewAskResponse("messageResponse", "proceed")
|
||||
session.awaitingInput = false
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Normal mode: Detect transition from processing to awaiting input
|
||||
const nowAwaitingInput = pendingState.awaitingApproval || pendingState.awaitingInput
|
||||
if (isProcessing && nowAwaitingInput && !wasAwaitingInput) {
|
||||
// AI just finished and is now waiting for input - show prompt
|
||||
setProcessingState(false)
|
||||
updatePromptString().then(() => showPrompt())
|
||||
}
|
||||
wasAwaitingInput = nowAwaitingInput
|
||||
})
|
||||
|
||||
// Build command context
|
||||
const commandContext = {
|
||||
session,
|
||||
fmt: formatter,
|
||||
logger,
|
||||
config,
|
||||
controller,
|
||||
}
|
||||
|
||||
// Handle line input
|
||||
rl.on("line", async (line: string) => {
|
||||
const input = line.trim()
|
||||
|
||||
if (!input) {
|
||||
// Empty input - just show prompt again
|
||||
await updatePromptString()
|
||||
showPrompt()
|
||||
return
|
||||
}
|
||||
|
||||
// Check for chat commands
|
||||
if (input.startsWith("/")) {
|
||||
await processSlashCommand(input, commandContext)
|
||||
if (!session.isRunning) {
|
||||
rl.close()
|
||||
return
|
||||
}
|
||||
// Commands complete immediately, show prompt
|
||||
await updatePromptString()
|
||||
showPrompt()
|
||||
return
|
||||
}
|
||||
|
||||
// Handle approval shortcuts
|
||||
if (session.awaitingApproval) {
|
||||
const lowerInput = input.toLowerCase()
|
||||
|
||||
// Check for "don't ask again" approval (yy, yes!, or approve!)
|
||||
const isAutoApprove = lowerInput === "yy" || lowerInput === "yes!" || lowerInput === "approve!"
|
||||
if (isAutoApprove) {
|
||||
if (controller.task && session.pendingAskMessage) {
|
||||
// Determine which auto-approval action to enable
|
||||
const actionKey = determineAutoApprovalAction(session.pendingAskMessage)
|
||||
if (actionKey) {
|
||||
// Enable auto-approval for this action type
|
||||
const currentAutoApproval = controller.stateManager.getGlobalSettingsKey("autoApprovalSettings")
|
||||
const updatedActions = {
|
||||
...currentAutoApproval.actions,
|
||||
[actionKey]: true,
|
||||
}
|
||||
controller.stateManager.setTaskSettings(session.taskId!, "autoApprovalSettings", {
|
||||
...currentAutoApproval,
|
||||
actions: updatedActions,
|
||||
})
|
||||
formatter.info(`Auto-approval enabled for ${actionKey}`)
|
||||
}
|
||||
|
||||
setProcessingState(true) // AI will start processing
|
||||
wasAwaitingInput = false
|
||||
await controller.task.handleWebviewAskResponse("yesButtonClicked")
|
||||
session.awaitingApproval = false
|
||||
session.pendingAskMessage = null
|
||||
}
|
||||
// Don't show prompt - wait for AI to finish
|
||||
return
|
||||
}
|
||||
|
||||
if (lowerInput === "y" || lowerInput === "yes" || lowerInput === "approve") {
|
||||
if (controller.task) {
|
||||
setProcessingState(true) // AI will start processing
|
||||
wasAwaitingInput = false
|
||||
await controller.task.handleWebviewAskResponse("yesButtonClicked")
|
||||
session.awaitingApproval = false
|
||||
session.pendingAskMessage = null
|
||||
}
|
||||
// Don't show prompt - wait for AI to finish
|
||||
return
|
||||
}
|
||||
if (lowerInput === "n" || lowerInput === "no" || lowerInput === "deny") {
|
||||
if (controller.task) {
|
||||
setProcessingState(true) // AI will start processing
|
||||
wasAwaitingInput = false
|
||||
await controller.task.handleWebviewAskResponse("noButtonClicked")
|
||||
session.awaitingApproval = false
|
||||
session.pendingAskMessage = null
|
||||
}
|
||||
// Don't show prompt - wait for AI to finish
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// If no active task, start a new one
|
||||
if (!session.taskId) {
|
||||
// Parse @path references from the input
|
||||
const parsed = parseAtPaths(input, process.cwd())
|
||||
|
||||
// Show warnings for any files that couldn't be processed
|
||||
for (const warning of parsed.warnings) {
|
||||
formatter.warn(warning)
|
||||
}
|
||||
|
||||
// Log attachment info
|
||||
if (parsed.files.length > 0) {
|
||||
formatter.info(`Attaching ${parsed.files.length} file(s)`)
|
||||
}
|
||||
if (parsed.images.length > 0) {
|
||||
formatter.info(`Attaching ${parsed.images.length} image(s)`)
|
||||
}
|
||||
|
||||
setProcessingState(true) // AI will start processing
|
||||
wasAwaitingInput = false
|
||||
session.taskId = await controller.initTask(
|
||||
parsed.cleanedMessage,
|
||||
parsed.images.length > 0 ? parsed.images : undefined,
|
||||
parsed.files.length > 0 ? parsed.files : undefined,
|
||||
)
|
||||
formatter.info(`Started task: ${session.taskId}`)
|
||||
session.adapter?.resetMessageCounter()
|
||||
// Don't show prompt - wait for AI to finish
|
||||
} else if (controller.task) {
|
||||
// Check if input is a numbered option selection
|
||||
let messageToSend = input
|
||||
let imagesToSend: string[] | undefined
|
||||
let filesToSend: string[] | undefined
|
||||
|
||||
if (session.awaitingInput && session.adapter) {
|
||||
const options = session.adapter.currentOptions
|
||||
const num = parseInt(input, 10)
|
||||
if (!Number.isNaN(num) && num >= 1 && num <= options.length) {
|
||||
messageToSend = options[num - 1]
|
||||
}
|
||||
}
|
||||
|
||||
// Parse @path references from the input (unless it's a numbered option)
|
||||
if (messageToSend === input) {
|
||||
const parsed = parseAtPaths(input, process.cwd())
|
||||
|
||||
// Show warnings for any files that couldn't be processed
|
||||
for (const warning of parsed.warnings) {
|
||||
formatter.warn(warning)
|
||||
}
|
||||
|
||||
// Log attachment info
|
||||
if (parsed.files.length > 0) {
|
||||
formatter.info(`Attaching ${parsed.files.length} file(s)`)
|
||||
}
|
||||
if (parsed.images.length > 0) {
|
||||
formatter.info(`Attaching ${parsed.images.length} image(s)`)
|
||||
}
|
||||
|
||||
messageToSend = parsed.cleanedMessage
|
||||
imagesToSend = parsed.images.length > 0 ? parsed.images : undefined
|
||||
filesToSend = parsed.files.length > 0 ? parsed.files : undefined
|
||||
}
|
||||
|
||||
setProcessingState(true) // AI will start processing
|
||||
wasAwaitingInput = false
|
||||
// Send message to existing task with any attachments
|
||||
await controller.task.handleWebviewAskResponse("messageResponse", messageToSend, imagesToSend, filesToSend)
|
||||
// Don't show prompt - wait for AI to finish
|
||||
}
|
||||
})
|
||||
|
||||
// Handle close
|
||||
rl.on("close", async () => {
|
||||
formatter.raw("")
|
||||
formatter.info("Chat session ended")
|
||||
|
||||
// Reset yolo mode settings if they were enabled for this session
|
||||
if (session.yoloMode) {
|
||||
controller.stateManager.setGlobalState("yoloModeToggled", false)
|
||||
// Reset maxConsecutiveMistakes to default
|
||||
controller.stateManager.setGlobalState("maxConsecutiveMistakes", 3)
|
||||
}
|
||||
|
||||
// Stop listening and cleanup
|
||||
session.adapter?.stopListening()
|
||||
await disposeEmbeddedController(logger)
|
||||
|
||||
process.exit(0)
|
||||
})
|
||||
|
||||
// Handle Ctrl+C
|
||||
rl.on("SIGINT", () => {
|
||||
formatter.raw("")
|
||||
formatter.info("Chat session ended (interrupted)")
|
||||
rl.close()
|
||||
})
|
||||
|
||||
// Start prompt with current state (only if not already processing)
|
||||
await updatePromptString()
|
||||
if (!isProcessing) {
|
||||
showPrompt()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the welcome message
|
||||
*/
|
||||
async function displayWelcome(formatter: OutputFormatter, session: ChatSession, controller: Controller): Promise<void> {
|
||||
formatter.raw("")
|
||||
formatter.info("═".repeat(60))
|
||||
if (session.yoloMode) {
|
||||
formatter.info(" Cline Interactive Chat Mode [YOLO]")
|
||||
} else {
|
||||
formatter.info(" Cline Interactive Chat Mode")
|
||||
}
|
||||
formatter.info("═".repeat(60))
|
||||
if (session.taskId) {
|
||||
formatter.info(`Task: ${session.taskId}`)
|
||||
}
|
||||
const state = await controller.getStateToPostToWebview()
|
||||
formatter.info(`Mode: ${state.mode || "act"}`)
|
||||
if (session.yoloMode) {
|
||||
formatter.info("YOLO: Auto-approving all actions (5min timeout, 3 retries max)")
|
||||
}
|
||||
formatter.raw("")
|
||||
if (!session.yoloMode) {
|
||||
formatter.info("Type your message and press Enter to send.")
|
||||
formatter.info("Use @path to attach files (e.g., @./file.txt, @image.png)")
|
||||
formatter.info("Press Tab to toggle between act/plan mode or complete @paths.")
|
||||
formatter.info("Type /help for available commands, /quit to exit.")
|
||||
}
|
||||
formatter.raw("─".repeat(60))
|
||||
formatter.raw("")
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Chat session state management
|
||||
*
|
||||
* Defines the session interface and factory function.
|
||||
*/
|
||||
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import type { CliWebviewAdapter } from "../../../core/cli-webview-adapter.js"
|
||||
|
||||
/**
|
||||
* Chat session state
|
||||
*/
|
||||
export interface ChatSession {
|
||||
taskId: string | null
|
||||
isRunning: boolean
|
||||
awaitingApproval: boolean
|
||||
awaitingInput: boolean
|
||||
adapter: CliWebviewAdapter | null
|
||||
yoloMode: boolean
|
||||
yoloFailureCount: number
|
||||
yoloLastFailedAction: string | null
|
||||
yoloActionStartTime: number | null
|
||||
yoloCompleted: boolean
|
||||
/** The current pending ask message (for determining auto-approval action type) */
|
||||
pendingAskMessage: ClineMessage | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new chat session with default state
|
||||
*/
|
||||
export function createSession(yoloMode = false): ChatSession {
|
||||
return {
|
||||
taskId: null,
|
||||
isRunning: true,
|
||||
awaitingApproval: false,
|
||||
awaitingInput: false,
|
||||
adapter: null,
|
||||
yoloMode,
|
||||
yoloFailureCount: 0,
|
||||
yoloLastFailedAction: null,
|
||||
yoloActionStartTime: null,
|
||||
yoloCompleted: false,
|
||||
pendingAskMessage: null,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Checkpoints command handler - list available checkpoints
|
||||
*/
|
||||
|
||||
import { formatCheckpointList } from "../../restore.js"
|
||||
import type { CommandContext, CommandHandler } from "./types.js"
|
||||
|
||||
/**
|
||||
* Handle /checkpoints command - list available checkpoints in current task
|
||||
*/
|
||||
export const handleCheckpoints: CommandHandler = async (_args: string[], ctx: CommandContext): Promise<boolean> => {
|
||||
if (!ctx.controller.task) {
|
||||
ctx.fmt.warn("No active task")
|
||||
return true
|
||||
}
|
||||
|
||||
const messages = ctx.controller.task.messageStateHandler.getClineMessages()
|
||||
const checkpoints = formatCheckpointList(messages)
|
||||
|
||||
if (checkpoints.length === 0) {
|
||||
ctx.fmt.info("No checkpoints found in current task")
|
||||
return true
|
||||
}
|
||||
|
||||
ctx.fmt.info(`Checkpoints (${checkpoints.length}):\n`)
|
||||
|
||||
const idWidth = 16
|
||||
const timeWidth = 16
|
||||
const wsWidth = 12
|
||||
|
||||
const header = "ID".padEnd(idWidth) + "Time".padEnd(timeWidth) + "Workspace".padEnd(wsWidth) + "Context"
|
||||
ctx.fmt.raw(header)
|
||||
ctx.fmt.raw("-".repeat(header.length + 30))
|
||||
|
||||
for (const cp of checkpoints) {
|
||||
const row =
|
||||
String(cp.id).padEnd(idWidth) +
|
||||
cp.timeAgo.padEnd(timeWidth) +
|
||||
(cp.hasWorkspaceRestore ? "Yes" : "No").padEnd(wsWidth) +
|
||||
cp.context
|
||||
ctx.fmt.raw(row)
|
||||
}
|
||||
|
||||
ctx.fmt.raw("")
|
||||
ctx.fmt.info('Use "/restore <checkpoint-id>" to restore')
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Config command handler
|
||||
*/
|
||||
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { getNestedValue, parseValue, setNestedValue } from "../../../config/index.js"
|
||||
import type { CommandContext, CommandHandler } from "./types.js"
|
||||
|
||||
/**
|
||||
* Handle /config, /cfg commands
|
||||
*/
|
||||
export const handleConfig: CommandHandler = async (args: string[], ctx: CommandContext): Promise<boolean> => {
|
||||
const subCmd = args[0]?.toLowerCase()
|
||||
const configKey = args[1]
|
||||
const configValue = args.slice(2).join(" ")
|
||||
|
||||
if (!subCmd || subCmd === "list" || subCmd === "ls") {
|
||||
// List all config values
|
||||
try {
|
||||
const configDir = ctx.config.configDir || `${process.env.HOME}/.cline`
|
||||
const globalStatePath = path.join(configDir, "data", "globalState.json")
|
||||
|
||||
if (fs.existsSync(globalStatePath)) {
|
||||
const content = fs.readFileSync(globalStatePath, "utf-8")
|
||||
const allSettings = JSON.parse(content)
|
||||
ctx.fmt.raw("")
|
||||
ctx.fmt.raw(JSON.stringify(allSettings, null, 2))
|
||||
ctx.fmt.raw("")
|
||||
} else {
|
||||
ctx.fmt.info("No configuration file found")
|
||||
}
|
||||
} catch (err) {
|
||||
ctx.fmt.error(`Failed to list config: ${(err as Error).message}`)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (subCmd === "get") {
|
||||
if (!configKey) {
|
||||
ctx.fmt.error("Usage: /config get <key>")
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
let value: unknown
|
||||
|
||||
if (configKey.includes(".")) {
|
||||
// For nested paths, get the root object first
|
||||
const rootKey = configKey.split(".")[0]
|
||||
let rootValue = ctx.controller.stateManager.getGlobalSettingsKey(rootKey as any)
|
||||
if (rootValue === undefined) {
|
||||
rootValue = ctx.controller.stateManager.getGlobalStateKey(rootKey as any)
|
||||
}
|
||||
|
||||
if (rootValue !== undefined && typeof rootValue === "object") {
|
||||
value = getNestedValue({ [rootKey]: rootValue }, configKey)
|
||||
}
|
||||
} else {
|
||||
value = ctx.controller.stateManager.getGlobalSettingsKey(configKey as any)
|
||||
if (value === undefined) {
|
||||
value = ctx.controller.stateManager.getGlobalStateKey(configKey as any)
|
||||
}
|
||||
}
|
||||
|
||||
if (value === undefined) {
|
||||
ctx.fmt.info(`${configKey} is not set`)
|
||||
} else {
|
||||
const displayValue = typeof value === "object" ? JSON.stringify(value, null, 2) : String(value)
|
||||
ctx.fmt.keyValue({ [configKey]: displayValue })
|
||||
}
|
||||
} catch (err) {
|
||||
ctx.fmt.error(`Failed to get config: ${(err as Error).message}`)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (subCmd === "set") {
|
||||
if (!configKey || !configValue) {
|
||||
ctx.fmt.error("Usage: /config set <key> <value>")
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedValue = parseValue(configKey, configValue)
|
||||
|
||||
if (configKey.includes(".")) {
|
||||
// For nested paths, get the current root object, modify it, and save the whole thing
|
||||
const rootKey = configKey.split(".")[0]
|
||||
let rootValue = ctx.controller.stateManager.getGlobalSettingsKey(rootKey as any)
|
||||
if (rootValue === undefined) {
|
||||
rootValue = ctx.controller.stateManager.getGlobalStateKey(rootKey as any)
|
||||
}
|
||||
|
||||
const currentRoot = rootValue !== undefined && typeof rootValue === "object" ? rootValue : {}
|
||||
const { rootValue: newRootValue } = setNestedValue({ [rootKey]: currentRoot }, configKey, parsedValue)
|
||||
|
||||
ctx.controller.stateManager.setGlobalState(rootKey as any, newRootValue as any)
|
||||
} else {
|
||||
ctx.controller.stateManager.setGlobalState(configKey as any, parsedValue as any)
|
||||
}
|
||||
|
||||
await ctx.controller.stateManager.flushPendingState()
|
||||
ctx.fmt.success(`Set ${configKey} = ${String(parsedValue)}`)
|
||||
} catch (err) {
|
||||
ctx.fmt.error(`Failed to set config: ${(err as Error).message}`)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (subCmd === "delete" || subCmd === "rm") {
|
||||
if (!configKey) {
|
||||
ctx.fmt.error("Usage: /config delete <key>")
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
ctx.controller.stateManager.setGlobalState(configKey as any, undefined)
|
||||
await ctx.controller.stateManager.flushPendingState()
|
||||
ctx.fmt.success(`Reset ${configKey} to default`)
|
||||
} catch (err) {
|
||||
ctx.fmt.error(`Failed to delete config: ${(err as Error).message}`)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
ctx.fmt.error(`Unknown config subcommand: ${subCmd}`)
|
||||
ctx.fmt.raw("Usage: /config <list|get|set|delete> [key] [value]")
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Help command handler
|
||||
*/
|
||||
|
||||
import type { CommandContext, CommandHandler } from "./types.js"
|
||||
|
||||
/**
|
||||
* Handle /help, /h, /? commands
|
||||
*/
|
||||
export const handleHelp: CommandHandler = async (_args: string[], ctx: CommandContext): Promise<boolean> => {
|
||||
ctx.fmt.raw("")
|
||||
ctx.fmt.info("Chat commands:")
|
||||
ctx.fmt.raw(" /help, /h, /? - Show this help")
|
||||
ctx.fmt.raw(" /plan - Switch to plan mode")
|
||||
ctx.fmt.raw(" /act - Switch to act mode")
|
||||
ctx.fmt.raw(" /mode <plan|act> - Switch mode")
|
||||
ctx.fmt.raw(" /model - Show current model")
|
||||
ctx.fmt.raw(" /model <id> - Set model for current mode")
|
||||
ctx.fmt.raw(" /model list - List available models (OpenRouter/Cline)")
|
||||
ctx.fmt.raw(" /status, /s - Show task status")
|
||||
ctx.fmt.raw(" /usage, /u - Show token usage and cost")
|
||||
ctx.fmt.raw(" /cancel - Cancel current task")
|
||||
ctx.fmt.raw(" /approve, /a, /y - Approve pending action")
|
||||
ctx.fmt.raw(" /deny, /d, /n - Deny pending action")
|
||||
ctx.fmt.raw(" /checkpoints, /cp - List available checkpoints")
|
||||
ctx.fmt.raw(" /restore, /r <id> [type] - Restore to checkpoint")
|
||||
ctx.fmt.raw(" types: task (default), workspace, taskAndWorkspace")
|
||||
ctx.fmt.raw(" /config, /cfg - Manage configuration")
|
||||
ctx.fmt.raw(" /config list - List all configuration values")
|
||||
ctx.fmt.raw(" /config get <key> - Get a config value")
|
||||
ctx.fmt.raw(" /config set <key> <value> - Set a config value")
|
||||
ctx.fmt.raw(" /config delete <key> - Reset a config value")
|
||||
ctx.fmt.raw(" /quit, /q, /exit - Exit chat mode")
|
||||
ctx.fmt.raw("")
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Command dispatcher for chat REPL
|
||||
*
|
||||
* Maps command names to their handlers and dispatches incoming commands.
|
||||
*/
|
||||
|
||||
import { handleCheckpoints } from "./checkpoints.js"
|
||||
import { handleConfig } from "./config.js"
|
||||
import { handleHelp } from "./help.js"
|
||||
import { handleAct, handleMode, handlePlan } from "./mode.js"
|
||||
import { handleModel } from "./model.js"
|
||||
import { handleQuit } from "./quit.js"
|
||||
import { handleRestore } from "./restore.js"
|
||||
import { handleStatus } from "./status.js"
|
||||
import { handleApprove, handleCancel, handleDeny } from "./task.js"
|
||||
import type { CommandContext, CommandHandler } from "./types.js"
|
||||
import { handleUsage } from "./usage.js"
|
||||
|
||||
/**
|
||||
* Map of command names to their handlers
|
||||
*/
|
||||
const handlers: Record<string, CommandHandler> = {
|
||||
// Help
|
||||
help: handleHelp,
|
||||
h: handleHelp,
|
||||
"?": handleHelp,
|
||||
|
||||
// Mode
|
||||
plan: handlePlan,
|
||||
act: handleAct,
|
||||
mode: handleMode,
|
||||
m: handleMode,
|
||||
|
||||
// Model
|
||||
model: handleModel,
|
||||
|
||||
// Status
|
||||
status: handleStatus,
|
||||
s: handleStatus,
|
||||
|
||||
// Task control
|
||||
cancel: handleCancel,
|
||||
approve: handleApprove,
|
||||
a: handleApprove,
|
||||
y: handleApprove,
|
||||
deny: handleDeny,
|
||||
d: handleDeny,
|
||||
n: handleDeny,
|
||||
|
||||
// Config
|
||||
config: handleConfig,
|
||||
cfg: handleConfig,
|
||||
|
||||
// Usage
|
||||
usage: handleUsage,
|
||||
u: handleUsage,
|
||||
|
||||
// Quit
|
||||
quit: handleQuit,
|
||||
q: handleQuit,
|
||||
exit: handleQuit,
|
||||
|
||||
// Checkpoints
|
||||
checkpoints: handleCheckpoints,
|
||||
cp: handleCheckpoints,
|
||||
restore: handleRestore,
|
||||
r: handleRestore,
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a chat command (input starting with /)
|
||||
*
|
||||
* @param input - Full command input including the leading /
|
||||
* @param ctx - Command context
|
||||
* @returns true if the command was handled
|
||||
*/
|
||||
export async function processSlashCommand(input: string, ctx: CommandContext): Promise<boolean> {
|
||||
const parts = input.slice(1).split(/\s+/)
|
||||
const cmd = parts[0].toLowerCase()
|
||||
const args = parts.slice(1)
|
||||
|
||||
const handler = handlers[cmd]
|
||||
if (!handler) {
|
||||
ctx.fmt.warn(`Unknown command: /${cmd}. Type /help for available commands.`)
|
||||
return true
|
||||
}
|
||||
|
||||
return handler(args, ctx)
|
||||
}
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { CommandContext, CommandHandler } from "./types.js"
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Mode command handlers
|
||||
*/
|
||||
|
||||
import type { CommandContext, CommandHandler } from "./types.js"
|
||||
|
||||
/**
|
||||
* Handle /plan command
|
||||
*/
|
||||
export const handlePlan: CommandHandler = async (_args: string[], ctx: CommandContext): Promise<boolean> => {
|
||||
await ctx.controller.togglePlanActMode("plan")
|
||||
ctx.fmt.success("Switched to plan mode")
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle /act command
|
||||
*/
|
||||
export const handleAct: CommandHandler = async (_args: string[], ctx: CommandContext): Promise<boolean> => {
|
||||
await ctx.controller.togglePlanActMode("act")
|
||||
ctx.fmt.success("Switched to act mode")
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle /mode command
|
||||
*/
|
||||
export const handleMode: CommandHandler = async (args: string[], ctx: CommandContext): Promise<boolean> => {
|
||||
if (args.length === 0) {
|
||||
const state = await ctx.controller.getStateToPostToWebview()
|
||||
ctx.fmt.info(`Current mode: ${state.mode || "unknown"}`)
|
||||
} else {
|
||||
const newMode = args[0].toLowerCase()
|
||||
if (newMode !== "plan" && newMode !== "act") {
|
||||
ctx.fmt.error("Invalid mode. Use 'plan' or 'act'")
|
||||
} else {
|
||||
await ctx.controller.togglePlanActMode(newMode as "plan" | "act")
|
||||
ctx.fmt.success(`Switched to ${newMode} mode`)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Model command handler
|
||||
*/
|
||||
|
||||
import type { Mode } from "@shared/storage/types"
|
||||
import { getModelIdForProvider, getModelIdKey } from "../model-utils.js"
|
||||
import type { CommandContext, CommandHandler } from "./types.js"
|
||||
|
||||
/**
|
||||
* Handle /model command
|
||||
*/
|
||||
export const handleModel: CommandHandler = async (args: string[], ctx: CommandContext): Promise<boolean> => {
|
||||
const state = await ctx.controller.getStateToPostToWebview()
|
||||
const currentMode: Mode = (state.mode as Mode) || "act"
|
||||
const apiConfig = state.apiConfiguration
|
||||
|
||||
// Get current provider for this mode
|
||||
const provider = currentMode === "plan" ? apiConfig?.planModeApiProvider : apiConfig?.actModeApiProvider
|
||||
|
||||
const subCmd = args[0]?.toLowerCase()
|
||||
|
||||
if (!subCmd) {
|
||||
// Show current model
|
||||
const modelId = getModelIdForProvider(apiConfig, provider, currentMode)
|
||||
ctx.fmt.raw("")
|
||||
ctx.fmt.info(`Mode: ${currentMode}`)
|
||||
ctx.fmt.info(`Provider: ${provider || "(not set)"}`)
|
||||
ctx.fmt.info(`Model: ${modelId || "(not set)"}`)
|
||||
ctx.fmt.raw("")
|
||||
return true
|
||||
}
|
||||
|
||||
if (subCmd === "list") {
|
||||
// Fetch models from OpenRouter if applicable
|
||||
if (provider === "openrouter" || provider === "cline") {
|
||||
ctx.fmt.info("Fetching models from OpenRouter...")
|
||||
try {
|
||||
const response = await fetch("https://openrouter.ai/api/v1/models")
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
const data = (await response.json()) as {
|
||||
data?: Array<{
|
||||
id: string
|
||||
name?: string
|
||||
pricing?: { prompt?: string; completion?: string }
|
||||
}>
|
||||
}
|
||||
const models = (data.data || []).sort((a, b) => a.id.localeCompare(b.id))
|
||||
|
||||
ctx.fmt.raw("")
|
||||
ctx.fmt.info(`Available models (${models.length} total):`)
|
||||
ctx.fmt.raw("")
|
||||
|
||||
// Show all models with pricing info (alphabetized)
|
||||
for (const model of models) {
|
||||
const promptPrice = model.pricing?.prompt
|
||||
? `$${(parseFloat(model.pricing.prompt) * 1_000_000).toFixed(2)}/M`
|
||||
: "N/A"
|
||||
const completionPrice = model.pricing?.completion
|
||||
? `$${(parseFloat(model.pricing.completion) * 1_000_000).toFixed(2)}/M`
|
||||
: "N/A"
|
||||
ctx.fmt.raw(` ${model.id}`)
|
||||
ctx.fmt.raw(` Input: ${promptPrice}, Output: ${completionPrice}`)
|
||||
}
|
||||
|
||||
ctx.fmt.raw("")
|
||||
ctx.fmt.info("Use '/model <model-id>' to set the model")
|
||||
ctx.fmt.raw("")
|
||||
} catch (err) {
|
||||
ctx.fmt.error(`Failed to fetch models: ${(err as Error).message}`)
|
||||
}
|
||||
} else {
|
||||
ctx.fmt.warn(`Model listing not available for provider: ${provider || "none"}`)
|
||||
ctx.fmt.info("Model listing is only supported for OpenRouter and Cline providers.")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Set model - args is the model ID (may contain slashes like "anthropic/claude-3")
|
||||
const newModelId = args.join(" ")
|
||||
|
||||
if (!provider) {
|
||||
ctx.fmt.error("No provider configured for current mode.")
|
||||
ctx.fmt.info("Run 'cline auth' to configure a provider first.")
|
||||
return true
|
||||
}
|
||||
|
||||
const modelIdKey = getModelIdKey(provider, currentMode)
|
||||
ctx.controller.stateManager.setGlobalState(modelIdKey as any, newModelId)
|
||||
await ctx.controller.stateManager.flushPendingState()
|
||||
ctx.fmt.success(`Set ${currentMode} mode model to: ${newModelId}`)
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Quit command handler
|
||||
*/
|
||||
|
||||
import type { CommandContext, CommandHandler } from "./types.js"
|
||||
|
||||
/**
|
||||
* Handle /quit, /q, /exit commands
|
||||
*/
|
||||
export const handleQuit: CommandHandler = async (_args: string[], ctx: CommandContext): Promise<boolean> => {
|
||||
ctx.session.isRunning = false
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Restore command handler - restore task to a checkpoint
|
||||
*/
|
||||
|
||||
import { validateCheckpoint } from "../../restore.js"
|
||||
import { handleCheckpoints } from "./checkpoints.js"
|
||||
import type { CommandContext, CommandHandler } from "./types.js"
|
||||
|
||||
/** Valid restore types */
|
||||
type RestoreType = "task" | "workspace" | "taskAndWorkspace"
|
||||
|
||||
const VALID_RESTORE_TYPES: RestoreType[] = ["task", "workspace", "taskAndWorkspace"]
|
||||
|
||||
/**
|
||||
* Get relative time string (e.g., "2 hours ago")
|
||||
*/
|
||||
function getTimeAgo(timestamp: number): string {
|
||||
const now = Date.now()
|
||||
const diff = now - timestamp
|
||||
const seconds = Math.floor(diff / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const days = Math.floor(hours / 24)
|
||||
|
||||
if (days > 0) {
|
||||
return days === 1 ? "1 day ago" : `${days} days ago`
|
||||
}
|
||||
if (hours > 0) {
|
||||
return hours === 1 ? "1 hour ago" : `${hours} hours ago`
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return minutes === 1 ? "1 minute ago" : `${minutes} minutes ago`
|
||||
}
|
||||
return "just now"
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle /restore command - restore task to a checkpoint
|
||||
*
|
||||
* Usage:
|
||||
* /restore <checkpoint-id> [type]
|
||||
* /restore list - List available checkpoints (alias for /checkpoints)
|
||||
*
|
||||
* Types:
|
||||
* task - Restore conversation only (default)
|
||||
* workspace - Restore files only
|
||||
* taskAndWorkspace - Restore both
|
||||
*/
|
||||
export const handleRestore: CommandHandler = async (args: string[], ctx: CommandContext): Promise<boolean> => {
|
||||
// Handle "list" subcommand
|
||||
if (args[0] === "list" || args[0] === "ls") {
|
||||
return handleCheckpoints(args.slice(1), ctx)
|
||||
}
|
||||
|
||||
// Check for active task
|
||||
if (!ctx.controller.task) {
|
||||
ctx.fmt.warn("No active task")
|
||||
return true
|
||||
}
|
||||
|
||||
// Validate arguments
|
||||
if (args.length === 0) {
|
||||
ctx.fmt.warn("Usage: /restore <checkpoint-id> [type]")
|
||||
ctx.fmt.info(" checkpoint-id: The timestamp ID of the checkpoint")
|
||||
ctx.fmt.info(" type: task (default), workspace, or taskAndWorkspace")
|
||||
ctx.fmt.info("")
|
||||
ctx.fmt.info('Use "/checkpoints" or "/restore list" to see available checkpoints')
|
||||
return true
|
||||
}
|
||||
|
||||
// Parse checkpoint ID
|
||||
const checkpointIdArg = args[0]
|
||||
const checkpointId = parseInt(checkpointIdArg, 10)
|
||||
if (isNaN(checkpointId)) {
|
||||
ctx.fmt.error(`Invalid checkpoint ID: "${checkpointIdArg}". Must be a number (timestamp).`)
|
||||
return true
|
||||
}
|
||||
|
||||
// Parse restore type (default: task)
|
||||
let restoreType: RestoreType = "task"
|
||||
if (args[1]) {
|
||||
const providedType = args[1].toLowerCase()
|
||||
if (!VALID_RESTORE_TYPES.includes(providedType as RestoreType)) {
|
||||
ctx.fmt.error(`Invalid restore type: "${args[1]}". Valid options: ${VALID_RESTORE_TYPES.join(", ")}`)
|
||||
return true
|
||||
}
|
||||
restoreType = providedType as RestoreType
|
||||
}
|
||||
|
||||
// Get messages and validate checkpoint exists
|
||||
const messages = ctx.controller.task.messageStateHandler.getClineMessages()
|
||||
const checkpoint = validateCheckpoint(messages, checkpointId)
|
||||
|
||||
if (!checkpoint) {
|
||||
// Check if the timestamp exists but is not a checkpoint
|
||||
const anyMessage = messages.find((m) => m.ts === checkpointId)
|
||||
if (anyMessage) {
|
||||
ctx.fmt.error(`Timestamp ${checkpointId} exists but is not a checkpoint (type: ${anyMessage.say || anyMessage.ask})`)
|
||||
} else {
|
||||
ctx.fmt.error(`Checkpoint ${checkpointId} not found in task history`)
|
||||
}
|
||||
ctx.fmt.info('Use "/checkpoints" to see available checkpoints')
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if workspace restore is possible
|
||||
if ((restoreType === "workspace" || restoreType === "taskAndWorkspace") && !checkpoint.lastCheckpointHash) {
|
||||
ctx.fmt.warn("Warning: This checkpoint does not have workspace restore data.")
|
||||
if (restoreType === "workspace") {
|
||||
ctx.fmt.error("Cannot restore workspace: no checkpoint hash available")
|
||||
return true
|
||||
}
|
||||
ctx.fmt.info("Falling back to task-only restore.")
|
||||
restoreType = "task"
|
||||
}
|
||||
|
||||
// Perform the restore
|
||||
ctx.fmt.info(`Restoring to checkpoint ${checkpointId} (${getTimeAgo(checkpointId)})...`)
|
||||
ctx.fmt.info(`Restore type: ${restoreType}`)
|
||||
|
||||
try {
|
||||
// Cancel any active task first (required before restore)
|
||||
await ctx.controller.cancelTask()
|
||||
|
||||
// Call restoreCheckpoint on the checkpoint manager
|
||||
const checkpointManager = ctx.controller.task?.checkpointManager
|
||||
if (!checkpointManager) {
|
||||
ctx.fmt.error("Checkpoint manager not available")
|
||||
return true
|
||||
}
|
||||
|
||||
await checkpointManager.restoreCheckpoint(checkpointId, restoreType)
|
||||
|
||||
ctx.fmt.success("Checkpoint restored successfully")
|
||||
|
||||
// Show post-restore state
|
||||
const newMessages = ctx.controller.task?.messageStateHandler.getClineMessages() || []
|
||||
ctx.fmt.info(`Task now has ${newMessages.length} messages`)
|
||||
} catch (error) {
|
||||
ctx.fmt.error(`Failed to restore checkpoint: ${(error as Error).message}`)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Status command handler
|
||||
*/
|
||||
|
||||
import type { CommandContext, CommandHandler } from "./types.js"
|
||||
|
||||
/**
|
||||
* Handle /status, /s commands
|
||||
*/
|
||||
export const handleStatus: CommandHandler = async (_args: string[], ctx: CommandContext): Promise<boolean> => {
|
||||
const state = await ctx.controller.getStateToPostToWebview()
|
||||
ctx.fmt.raw("")
|
||||
ctx.fmt.info(`Task ID: ${ctx.session.taskId || "none"}`)
|
||||
ctx.fmt.info(`Mode: ${state.mode || "unknown"}`)
|
||||
ctx.fmt.info(`Messages: ${state.clineMessages?.length || 0}`)
|
||||
if (ctx.session.awaitingApproval) {
|
||||
ctx.fmt.warn("Awaiting approval (use /approve or /deny)")
|
||||
}
|
||||
if (ctx.session.awaitingInput) {
|
||||
ctx.fmt.warn("Awaiting user input")
|
||||
}
|
||||
ctx.fmt.raw("")
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Task-related command handlers (cancel, approve, deny)
|
||||
*/
|
||||
|
||||
import type { CommandContext, CommandHandler } from "./types.js"
|
||||
|
||||
/**
|
||||
* Handle /cancel command
|
||||
*/
|
||||
export const handleCancel: CommandHandler = async (_args: string[], ctx: CommandContext): Promise<boolean> => {
|
||||
if (ctx.controller.task) {
|
||||
await ctx.controller.cancelTask()
|
||||
ctx.fmt.success("Task cancelled")
|
||||
} else {
|
||||
ctx.fmt.warn("No active task to cancel")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle /approve, /a, /y commands
|
||||
*/
|
||||
export const handleApprove: CommandHandler = async (_args: string[], ctx: CommandContext): Promise<boolean> => {
|
||||
if (!ctx.session.awaitingApproval) {
|
||||
ctx.fmt.warn("No pending approval request")
|
||||
} else if (ctx.controller.task) {
|
||||
await ctx.controller.task.handleWebviewAskResponse("yesButtonClicked")
|
||||
ctx.session.awaitingApproval = false
|
||||
ctx.fmt.success("Action approved")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle /deny, /d, /n commands
|
||||
*/
|
||||
export const handleDeny: CommandHandler = async (_args: string[], ctx: CommandContext): Promise<boolean> => {
|
||||
if (!ctx.session.awaitingApproval) {
|
||||
ctx.fmt.warn("No pending approval request")
|
||||
} else if (ctx.controller.task) {
|
||||
await ctx.controller.task.handleWebviewAskResponse("noButtonClicked")
|
||||
ctx.session.awaitingApproval = false
|
||||
ctx.fmt.success("Action denied")
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Types for chat command handlers
|
||||
*/
|
||||
|
||||
import type { Controller } from "@/core/controller"
|
||||
import type { OutputFormatter } from "../../../../core/output/types.js"
|
||||
import type { CliConfig } from "../../../../types/config.js"
|
||||
import type { Logger } from "../../../../types/logger.js"
|
||||
import type { ChatSession } from "../session.js"
|
||||
|
||||
/**
|
||||
* Context passed to all command handlers
|
||||
*/
|
||||
export interface CommandContext {
|
||||
session: ChatSession
|
||||
fmt: OutputFormatter
|
||||
logger: Logger
|
||||
config: CliConfig
|
||||
controller: Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler function for a chat command
|
||||
* @param args - Arguments after the command name
|
||||
* @param ctx - Command context with session, formatter, etc.
|
||||
* @returns true if the command was handled (input should not be passed to AI)
|
||||
*/
|
||||
export type CommandHandler = (args: string[], ctx: CommandContext) => Promise<boolean>
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Usage command handler
|
||||
*
|
||||
* Displays token usage and cost for the current conversation
|
||||
*/
|
||||
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { getApiMetrics } from "@shared/getApiMetrics"
|
||||
import type { CommandContext, CommandHandler } from "./types.js"
|
||||
|
||||
/**
|
||||
* Count API requests from messages
|
||||
*/
|
||||
function countApiRequests(messages: ClineMessage[]): number {
|
||||
return messages.filter((msg) => msg.type === "say" && msg.say === "api_req_started").length
|
||||
}
|
||||
|
||||
/**
|
||||
* Format number with commas
|
||||
*/
|
||||
function formatNumber(n: number): string {
|
||||
return n.toLocaleString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle /usage, /u commands
|
||||
*/
|
||||
export const handleUsage: CommandHandler = async (_args: string[], ctx: CommandContext): Promise<boolean> => {
|
||||
// Get messages from the current session
|
||||
const messages = ctx.controller.task?.messageStateHandler.getClineMessages() || []
|
||||
|
||||
if (messages.length === 0) {
|
||||
ctx.fmt.warn("No messages in current conversation")
|
||||
return true
|
||||
}
|
||||
|
||||
const metrics = getApiMetrics(messages)
|
||||
const requestCount = countApiRequests(messages)
|
||||
|
||||
ctx.fmt.raw("")
|
||||
ctx.fmt.info("📊 Token Usage & Cost")
|
||||
ctx.fmt.raw("")
|
||||
ctx.fmt.raw(` Input tokens: ${formatNumber(metrics.totalTokensIn)}`)
|
||||
ctx.fmt.raw(` Output tokens: ${formatNumber(metrics.totalTokensOut)}`)
|
||||
ctx.fmt.raw(` Total tokens: ${formatNumber(metrics.totalTokensIn + metrics.totalTokensOut)}`)
|
||||
|
||||
// Show cache metrics if available
|
||||
if (metrics.totalCacheWrites !== undefined || metrics.totalCacheReads !== undefined) {
|
||||
ctx.fmt.raw("")
|
||||
ctx.fmt.raw(` Cache writes: ${formatNumber(metrics.totalCacheWrites ?? 0)}`)
|
||||
ctx.fmt.raw(` Cache reads: ${formatNumber(metrics.totalCacheReads ?? 0)}`)
|
||||
}
|
||||
|
||||
ctx.fmt.raw("")
|
||||
ctx.fmt.raw(` API requests: ${requestCount}`)
|
||||
ctx.fmt.raw(` Total cost: $${metrics.totalCost.toFixed(4)}`)
|
||||
ctx.fmt.raw("")
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Task dump command - output raw JSON of conversation messages
|
||||
*
|
||||
* This command outputs the raw JSON of a task's ClineMessages array,
|
||||
* useful for debugging or external processing.
|
||||
*/
|
||||
|
||||
import { getSavedClineMessages, readTaskHistoryFromState } from "@core/storage/disk"
|
||||
import { Command } from "commander"
|
||||
import { initializeHostProviderOnly } from "../../core/embedded-controller.js"
|
||||
import type { OutputFormatter } from "../../core/output/types.js"
|
||||
import type { CliConfig } from "../../types/config.js"
|
||||
import type { Logger } from "../../types/logger.js"
|
||||
|
||||
/**
|
||||
* Create the task dump command
|
||||
*/
|
||||
export function createTaskDumpCommand(config: CliConfig, logger: Logger, formatter: OutputFormatter): Command {
|
||||
const dumpCommand = new Command("dump")
|
||||
.alias("d")
|
||||
.description("Output raw JSON of task conversation messages")
|
||||
.argument("[taskId]", "Task ID to dump (defaults to current or most recent task)")
|
||||
.action(async (taskIdArg: string | undefined) => {
|
||||
logger.debug("Task dump command called", { taskIdArg })
|
||||
|
||||
try {
|
||||
// Initialize HostProvider only (lightweight, no full controller)
|
||||
initializeHostProviderOnly(logger, config.configDir)
|
||||
|
||||
// Read task history directly from disk
|
||||
const taskHistory = await readTaskHistoryFromState()
|
||||
|
||||
// Determine which task to dump
|
||||
let taskId = taskIdArg
|
||||
|
||||
if (taskId) {
|
||||
// Find task by ID (support partial ID match)
|
||||
const historyItem = taskHistory.find((t) => t.id === taskId || t.id.startsWith(taskId || ""))
|
||||
if (!historyItem) {
|
||||
throw new Error(`Task not found: ${taskId}`)
|
||||
}
|
||||
taskId = historyItem.id
|
||||
} else {
|
||||
// Use most recent task
|
||||
if (taskHistory.length > 0) {
|
||||
taskId = taskHistory[0].id
|
||||
} else {
|
||||
throw new Error("No tasks found. Create a task with 'cline task new'")
|
||||
}
|
||||
}
|
||||
|
||||
// Read messages directly from disk storage
|
||||
const messages = await getSavedClineMessages(taskId)
|
||||
formatter.raw(JSON.stringify(messages, null, 2))
|
||||
} catch (error) {
|
||||
formatter.error((error as Error).message)
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
|
||||
return dumpCommand
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Task command group - manage Cline tasks
|
||||
*/
|
||||
|
||||
import { Command } from "commander"
|
||||
import type { OutputFormatter } from "../../core/output/types.js"
|
||||
import type { CliConfig } from "../../types/config.js"
|
||||
import type { Logger } from "../../types/logger.js"
|
||||
import { createTaskChatCommand } from "./chat/index.js"
|
||||
import { createTaskDumpCommand } from "./dump.js"
|
||||
import { createTaskListCommand } from "./list.js"
|
||||
import { createTaskRestoreCommand } from "./restore.js"
|
||||
import { createTaskSendCommand } from "./send.js"
|
||||
import { createTaskViewCommand } from "./view.js"
|
||||
|
||||
/**
|
||||
* Create the task command group
|
||||
*/
|
||||
export function createTaskCommand(config: CliConfig, logger: Logger, formatter: OutputFormatter): Command {
|
||||
const taskCommand = new Command("task").alias("t").description("Manage Cline tasks")
|
||||
|
||||
// Add subcommands
|
||||
taskCommand.addCommand(createTaskListCommand(config, logger, formatter))
|
||||
taskCommand.addCommand(createTaskChatCommand(config, logger, formatter))
|
||||
taskCommand.addCommand(createTaskSendCommand(config, logger, formatter))
|
||||
taskCommand.addCommand(createTaskViewCommand(config, logger, formatter))
|
||||
taskCommand.addCommand(createTaskRestoreCommand(config, logger, formatter))
|
||||
taskCommand.addCommand(createTaskDumpCommand(config, logger, formatter))
|
||||
|
||||
return taskCommand
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Task list command - list task history
|
||||
*
|
||||
* This command uses Cline's EmbeddedController to read task history directly,
|
||||
* ensuring CLI task list matches what the extension shows.
|
||||
*/
|
||||
|
||||
import { Command } from "commander"
|
||||
import { disposeEmbeddedController, getEmbeddedController } from "../../core/embedded-controller.js"
|
||||
import type { OutputFormatter } from "../../core/output/types.js"
|
||||
import type { CliConfig } from "../../types/config.js"
|
||||
import type { Logger } from "../../types/logger.js"
|
||||
|
||||
/**
|
||||
* Get relative time string (e.g., "2 hours ago")
|
||||
*/
|
||||
function getTimeAgo(timestamp: number): string {
|
||||
const now = Date.now()
|
||||
const diff = now - timestamp
|
||||
const seconds = Math.floor(diff / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const days = Math.floor(hours / 24)
|
||||
const weeks = Math.floor(days / 7)
|
||||
const months = Math.floor(days / 30)
|
||||
|
||||
if (months > 0) {
|
||||
return months === 1 ? "1 month ago" : `${months} months ago`
|
||||
}
|
||||
if (weeks > 0) {
|
||||
return weeks === 1 ? "1 week ago" : `${weeks} weeks ago`
|
||||
}
|
||||
if (days > 0) {
|
||||
return days === 1 ? "1 day ago" : `${days} days ago`
|
||||
}
|
||||
if (hours > 0) {
|
||||
return hours === 1 ? "1 hour ago" : `${hours} hours ago`
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return minutes === 1 ? "1 minute ago" : `${minutes} minutes ago`
|
||||
}
|
||||
return "just now"
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate a string to a maximum length with ellipsis
|
||||
*/
|
||||
function truncate(str: string, maxLength: number): string {
|
||||
if (str.length <= maxLength) {
|
||||
return str
|
||||
}
|
||||
return str.slice(0, maxLength - 3) + "..."
|
||||
}
|
||||
|
||||
/**
|
||||
* Format cost as a currency string
|
||||
*/
|
||||
function formatCost(cost: number): string {
|
||||
if (cost === 0) {
|
||||
return "$0.00"
|
||||
}
|
||||
if (cost < 0.01) {
|
||||
return `$${cost.toFixed(4)}`
|
||||
}
|
||||
return `$${cost.toFixed(2)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the task list command
|
||||
*/
|
||||
export function createTaskListCommand(config: CliConfig, logger: Logger, formatter: OutputFormatter): Command {
|
||||
const listCommand = new Command("list")
|
||||
.alias("l")
|
||||
.alias("ls")
|
||||
.description("List task history")
|
||||
.option("-n, --limit <number>", "Maximum number of tasks to show", "20")
|
||||
.option("-a, --all", "Show all tasks (no limit)", false)
|
||||
.action(async (options) => {
|
||||
logger.debug("Task list command called", { options })
|
||||
|
||||
try {
|
||||
// Initialize embedded controller to access task history
|
||||
const controller = await getEmbeddedController(logger, config.configDir)
|
||||
|
||||
// Get task history from the state
|
||||
const state = await controller.getStateToPostToWebview()
|
||||
let tasks = state.taskHistory || []
|
||||
|
||||
// Parse limit
|
||||
const limit = options.all ? undefined : parseInt(options.limit, 10)
|
||||
if (limit !== undefined && (Number.isNaN(limit) || limit < 1)) {
|
||||
formatter.error("Invalid limit value")
|
||||
await disposeEmbeddedController(logger)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Apply limit if specified
|
||||
if (limit !== undefined) {
|
||||
tasks = tasks.slice(0, limit)
|
||||
}
|
||||
|
||||
logger.debug(`Found ${tasks.length} tasks`)
|
||||
|
||||
// Handle empty list
|
||||
if (tasks.length === 0) {
|
||||
formatter.info("No tasks found")
|
||||
if (config.outputFormat === "json") {
|
||||
formatter.raw("[]")
|
||||
}
|
||||
await disposeEmbeddedController(logger)
|
||||
process.exit(0)
|
||||
return
|
||||
}
|
||||
|
||||
// Output based on format
|
||||
if (config.outputFormat === "json") {
|
||||
// JSON output: full task info
|
||||
formatter.raw(JSON.stringify(tasks, null, 2))
|
||||
} else {
|
||||
// Rich/plain output: formatted table
|
||||
formatter.info(`Task History (${tasks.length} task${tasks.length === 1 ? "" : "s"}):\n`)
|
||||
|
||||
// Calculate column widths for alignment
|
||||
const idWidth = 15
|
||||
const timeWidth = 16
|
||||
const costWidth = 10
|
||||
const modelWidth = 20
|
||||
|
||||
// Header
|
||||
const header =
|
||||
"ID".padEnd(idWidth) +
|
||||
"Time".padEnd(timeWidth) +
|
||||
"Cost".padEnd(costWidth) +
|
||||
"Model".padEnd(modelWidth) +
|
||||
"Prompt"
|
||||
formatter.raw(header)
|
||||
formatter.raw("-".repeat(header.length + 20))
|
||||
|
||||
// Rows
|
||||
for (const task of tasks) {
|
||||
const row =
|
||||
task.id.padEnd(idWidth) +
|
||||
getTimeAgo(task.ts).padEnd(timeWidth) +
|
||||
formatCost(task.totalCost).padEnd(costWidth) +
|
||||
truncate(task.modelId || "unknown", modelWidth - 2).padEnd(modelWidth) +
|
||||
truncate(task.task.replace(/\n/g, " "), 50)
|
||||
formatter.raw(row)
|
||||
}
|
||||
|
||||
formatter.raw("")
|
||||
formatter.info('Use "cline task open <id>" to resume a task')
|
||||
}
|
||||
|
||||
// Cleanup and exit
|
||||
await disposeEmbeddedController(logger)
|
||||
process.exit(0)
|
||||
} catch (error) {
|
||||
formatter.error((error as Error).message)
|
||||
await disposeEmbeddedController(logger)
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
|
||||
return listCommand
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Task restore command - restore a task to a specific checkpoint
|
||||
*
|
||||
* This command restores a task to a previous checkpoint, optionally
|
||||
* restoring both the conversation state and workspace files.
|
||||
*/
|
||||
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { Command } from "commander"
|
||||
import { disposeEmbeddedController, getEmbeddedController } from "../../core/embedded-controller.js"
|
||||
import type { OutputFormatter } from "../../core/output/types.js"
|
||||
import type { CliConfig } from "../../types/config.js"
|
||||
import type { Logger } from "../../types/logger.js"
|
||||
|
||||
/** Valid restore types */
|
||||
type RestoreType = "task" | "workspace" | "taskAndWorkspace"
|
||||
|
||||
const VALID_RESTORE_TYPES: RestoreType[] = ["task", "workspace", "taskAndWorkspace"]
|
||||
|
||||
/**
|
||||
* Get relative time string (e.g., "2 hours ago")
|
||||
*/
|
||||
function getTimeAgo(timestamp: number): string {
|
||||
const now = Date.now()
|
||||
const diff = now - timestamp
|
||||
const seconds = Math.floor(diff / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const days = Math.floor(hours / 24)
|
||||
|
||||
if (days > 0) {
|
||||
return days === 1 ? "1 day ago" : `${days} days ago`
|
||||
}
|
||||
if (hours > 0) {
|
||||
return hours === 1 ? "1 hour ago" : `${hours} hours ago`
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return minutes === 1 ? "1 minute ago" : `${minutes} minutes ago`
|
||||
}
|
||||
return "just now"
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate a string to a maximum length with ellipsis
|
||||
*/
|
||||
function truncate(str: string, maxLength: number): string {
|
||||
if (str.length <= maxLength) {
|
||||
return str
|
||||
}
|
||||
return str.slice(0, maxLength - 3) + "..."
|
||||
}
|
||||
|
||||
/**
|
||||
* Find checkpoints in a list of messages
|
||||
*/
|
||||
export function findCheckpoints(messages: ClineMessage[]): ClineMessage[] {
|
||||
return messages.filter((m) => m.say === "checkpoint_created")
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a checkpoint ID exists in the messages
|
||||
*/
|
||||
export function validateCheckpoint(messages: ClineMessage[], checkpointId: number): ClineMessage | null {
|
||||
return messages.find((m) => m.ts === checkpointId && m.say === "checkpoint_created") || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get context for a checkpoint (the preceding user message)
|
||||
*/
|
||||
function getCheckpointContext(messages: ClineMessage[], checkpointIndex: number): string {
|
||||
// Look backwards for the most recent user message
|
||||
for (let i = checkpointIndex - 1; i >= 0; i--) {
|
||||
const msg = messages[i]
|
||||
if (msg.type === "say" && msg.say === "text" && msg.text) {
|
||||
return truncate(msg.text.replace(/\n/g, " "), 50)
|
||||
}
|
||||
if (msg.type === "ask" && msg.text) {
|
||||
return truncate(msg.text.replace(/\n/g, " "), 50)
|
||||
}
|
||||
}
|
||||
return "(no context)"
|
||||
}
|
||||
|
||||
/**
|
||||
* Format checkpoints for display
|
||||
*/
|
||||
export function formatCheckpointList(messages: ClineMessage[]): Array<{
|
||||
id: number
|
||||
timeAgo: string
|
||||
context: string
|
||||
hasWorkspaceRestore: boolean
|
||||
}> {
|
||||
const checkpoints = findCheckpoints(messages)
|
||||
return checkpoints.map((cp) => {
|
||||
const index = messages.indexOf(cp)
|
||||
return {
|
||||
id: cp.ts,
|
||||
timeAgo: getTimeAgo(cp.ts),
|
||||
context: getCheckpointContext(messages, index),
|
||||
hasWorkspaceRestore: !!cp.lastCheckpointHash,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the task restore command
|
||||
*/
|
||||
export function createTaskRestoreCommand(config: CliConfig, logger: Logger, formatter: OutputFormatter): Command {
|
||||
const restoreCommand = new Command("restore")
|
||||
.alias("r")
|
||||
.description("Restore task to a specific checkpoint")
|
||||
.argument("<checkpoint-id>", "Checkpoint ID (timestamp) to restore to")
|
||||
.option(
|
||||
"-t, --type <type>",
|
||||
"Restore type: task (conversation only), workspace (files only), taskAndWorkspace (both)",
|
||||
"task",
|
||||
)
|
||||
.option("-l, --list", "List available checkpoints instead of restoring", false)
|
||||
.action(async (checkpointIdArg: string, options) => {
|
||||
logger.debug("Task restore command called", { checkpointIdArg, options })
|
||||
|
||||
try {
|
||||
// Initialize embedded controller
|
||||
const controller = await getEmbeddedController(logger, config.configDir)
|
||||
|
||||
// Check if there's an active task
|
||||
if (!controller.task) {
|
||||
// Try to get the most recent task
|
||||
const state = await controller.getStateToPostToWebview()
|
||||
const taskHistory = state.taskHistory || []
|
||||
|
||||
if (taskHistory.length === 0) {
|
||||
throw new Error("No tasks found. Create a task first.")
|
||||
}
|
||||
|
||||
// Initialize the most recent task
|
||||
const historyItem = taskHistory[0]
|
||||
const taskData = await controller.getTaskWithId(historyItem.id)
|
||||
await controller.initTask(undefined, undefined, undefined, taskData.historyItem)
|
||||
}
|
||||
|
||||
// Get messages from the task
|
||||
const messages = controller.task?.messageStateHandler.getClineMessages() || []
|
||||
|
||||
if (messages.length === 0) {
|
||||
throw new Error("No messages in current task")
|
||||
}
|
||||
|
||||
// Handle --list option
|
||||
if (options.list) {
|
||||
const checkpoints = formatCheckpointList(messages)
|
||||
|
||||
if (checkpoints.length === 0) {
|
||||
formatter.info("No checkpoints found in current task")
|
||||
await disposeEmbeddedController(logger)
|
||||
return
|
||||
}
|
||||
|
||||
if (config.outputFormat === "json") {
|
||||
formatter.raw(JSON.stringify(checkpoints, null, 2))
|
||||
} else {
|
||||
formatter.info(`Checkpoints (${checkpoints.length}):\n`)
|
||||
|
||||
const idWidth = 16
|
||||
const timeWidth = 16
|
||||
const wsWidth = 12
|
||||
|
||||
const header = "ID".padEnd(idWidth) + "Time".padEnd(timeWidth) + "Workspace".padEnd(wsWidth) + "Context"
|
||||
formatter.raw(header)
|
||||
formatter.raw("-".repeat(header.length + 30))
|
||||
|
||||
for (const cp of checkpoints) {
|
||||
const row =
|
||||
String(cp.id).padEnd(idWidth) +
|
||||
cp.timeAgo.padEnd(timeWidth) +
|
||||
(cp.hasWorkspaceRestore ? "Yes" : "No").padEnd(wsWidth) +
|
||||
cp.context
|
||||
formatter.raw(row)
|
||||
}
|
||||
|
||||
formatter.raw("")
|
||||
formatter.info('Use "cline task restore <checkpoint-id>" to restore')
|
||||
}
|
||||
|
||||
await disposeEmbeddedController(logger)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse and validate checkpoint ID
|
||||
const checkpointId = parseInt(checkpointIdArg, 10)
|
||||
if (isNaN(checkpointId)) {
|
||||
throw new Error(`Invalid checkpoint ID: "${checkpointIdArg}". Must be a number (timestamp).`)
|
||||
}
|
||||
|
||||
// Validate restore type
|
||||
const restoreType = options.type as RestoreType
|
||||
if (!VALID_RESTORE_TYPES.includes(restoreType)) {
|
||||
throw new Error(`Invalid restore type: "${restoreType}". Valid options: ${VALID_RESTORE_TYPES.join(", ")}`)
|
||||
}
|
||||
|
||||
// Validate checkpoint exists
|
||||
const checkpoint = validateCheckpoint(messages, checkpointId)
|
||||
if (!checkpoint) {
|
||||
// Check if the timestamp exists but is not a checkpoint
|
||||
const anyMessage = messages.find((m) => m.ts === checkpointId)
|
||||
if (anyMessage) {
|
||||
throw new Error(
|
||||
`Timestamp ${checkpointId} exists but is not a checkpoint (type: ${anyMessage.say || anyMessage.ask})`,
|
||||
)
|
||||
}
|
||||
throw new Error(`Checkpoint ${checkpointId} not found in task history`)
|
||||
}
|
||||
|
||||
// Check if workspace restore is possible
|
||||
if ((restoreType === "workspace" || restoreType === "taskAndWorkspace") && !checkpoint.lastCheckpointHash) {
|
||||
formatter.warn("Warning: This checkpoint does not have workspace restore data.")
|
||||
if (restoreType === "workspace") {
|
||||
throw new Error("Cannot restore workspace: no checkpoint hash available")
|
||||
}
|
||||
formatter.info("Falling back to task-only restore.")
|
||||
}
|
||||
|
||||
// Perform the restore
|
||||
formatter.info(`Restoring to checkpoint ${checkpointId} (${getTimeAgo(checkpointId)})...`)
|
||||
formatter.info(`Restore type: ${restoreType}`)
|
||||
|
||||
// Cancel any active task first (required before restore)
|
||||
await controller.cancelTask()
|
||||
|
||||
// Call restoreCheckpoint on the checkpoint manager
|
||||
const checkpointManager = controller.task?.checkpointManager
|
||||
if (!checkpointManager) {
|
||||
throw new Error("Checkpoint manager not available")
|
||||
}
|
||||
|
||||
await checkpointManager.restoreCheckpoint(checkpointId, restoreType)
|
||||
|
||||
formatter.success("Checkpoint restored successfully")
|
||||
|
||||
// Show post-restore state
|
||||
const newMessages = controller.task?.messageStateHandler.getClineMessages() || []
|
||||
formatter.info(`Task now has ${newMessages.length} messages`)
|
||||
|
||||
await disposeEmbeddedController(logger)
|
||||
} catch (error) {
|
||||
formatter.error((error as Error).message)
|
||||
await disposeEmbeddedController(logger)
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
|
||||
return restoreCommand
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
/**
|
||||
* Task send command - send a message to the current task using embedded Controller
|
||||
*
|
||||
* This command sends a single message to an active task using Cline's
|
||||
* embedded Controller, allowing non-interactive AI interactions.
|
||||
*/
|
||||
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { Command } from "commander"
|
||||
import { CliWebviewAdapter } from "../../core/cli-webview-adapter.js"
|
||||
import { disposeEmbeddedController, getControllerIfInitialized, getEmbeddedController } from "../../core/embedded-controller.js"
|
||||
import type { OutputFormatter } from "../../core/output/types.js"
|
||||
import { parseAtPaths, processExplicitFiles, processExplicitImages } from "../../core/path-parser.js"
|
||||
import type { CliConfig } from "../../types/config.js"
|
||||
import type { Logger } from "../../types/logger.js"
|
||||
import { checkForPendingInput, isCompletionState, isFailureState } from "./chat/input-checker.js"
|
||||
|
||||
/** Yolo mode timeout: 5 minutes in milliseconds */
|
||||
const YOLO_TIMEOUT_MS = 5 * 60 * 1000
|
||||
|
||||
/** Yolo mode max consecutive failures before abort */
|
||||
const YOLO_MAX_FAILURES = 3
|
||||
|
||||
/**
|
||||
* Validate mode option
|
||||
*/
|
||||
function validateMode(mode: string | undefined): "act" | "plan" | undefined {
|
||||
if (!mode) {
|
||||
return undefined
|
||||
}
|
||||
if (mode !== "act" && mode !== "plan") {
|
||||
throw new Error(`Invalid mode: "${mode}". Valid options are: act, plan`)
|
||||
}
|
||||
return mode
|
||||
}
|
||||
|
||||
/**
|
||||
* Read input from stdin if available
|
||||
*/
|
||||
async function readStdin(): Promise<string | null> {
|
||||
// Check if stdin is a TTY (interactive terminal)
|
||||
if (process.stdin.isTTY) {
|
||||
return null
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let data = ""
|
||||
process.stdin.setEncoding("utf-8")
|
||||
|
||||
process.stdin.on("readable", () => {
|
||||
let chunk: string | null
|
||||
while ((chunk = process.stdin.read() as string | null) !== null) {
|
||||
data += chunk
|
||||
}
|
||||
})
|
||||
|
||||
process.stdin.on("end", () => {
|
||||
resolve(data.trim() || null)
|
||||
})
|
||||
|
||||
// Timeout after 100ms if no data
|
||||
setTimeout(() => {
|
||||
if (!data) {
|
||||
resolve(null)
|
||||
}
|
||||
}, 100)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the last message requires user input
|
||||
*/
|
||||
function isAwaitingResponse(messages: ClineMessage[]): boolean {
|
||||
if (messages.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
|
||||
// Skip partial messages
|
||||
if (lastMessage.partial) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if this is an "ask" type message
|
||||
return lastMessage.type === "ask"
|
||||
}
|
||||
|
||||
/**
|
||||
* Yolo mode state for tracking failures
|
||||
*/
|
||||
interface YoloState {
|
||||
failureCount: number
|
||||
lastFailedAction: string | null
|
||||
actionStartTime: number
|
||||
completed: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for task to reach a stopping point (either completion or awaiting input)
|
||||
* In yolo mode, auto-approves actions and continues until completion
|
||||
*/
|
||||
async function waitForTaskResponse(
|
||||
controller: Awaited<ReturnType<typeof getEmbeddedController>>,
|
||||
formatter: OutputFormatter,
|
||||
timeoutMs = 300000, // 5 minutes default timeout
|
||||
yoloMode = false,
|
||||
): Promise<void> {
|
||||
const adapter = new CliWebviewAdapter(controller, formatter)
|
||||
adapter.startListening()
|
||||
|
||||
const yoloState: YoloState = {
|
||||
failureCount: 0,
|
||||
lastFailedAction: null,
|
||||
actionStartTime: Date.now(),
|
||||
completed: false,
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const startTime = Date.now()
|
||||
|
||||
const checkInterval = setInterval(async () => {
|
||||
const messages = adapter.getMessages()
|
||||
|
||||
// YOLO MODE: Auto-respond and continue until completion
|
||||
if (yoloMode && controller.task && !yoloState.completed) {
|
||||
// Check for task completion first
|
||||
if (isCompletionState(messages)) {
|
||||
// Guard against processing completion multiple times
|
||||
yoloState.completed = true
|
||||
formatter.success("\n[YOLO] Task completed!")
|
||||
clearInterval(checkInterval)
|
||||
adapter.stopListening()
|
||||
// Respond to the completion_result ask to unblock the handler
|
||||
const task = controller.task
|
||||
await task.handleWebviewAskResponse("yesButtonClicked")
|
||||
// Give time for the response to be fully processed
|
||||
await new Promise((r) => setTimeout(r, 200))
|
||||
// Abort the task to stop the loop - this is expected after completion
|
||||
try {
|
||||
await task.abortTask()
|
||||
} catch {
|
||||
// Task may already be cleaned up, ignore
|
||||
}
|
||||
// Exit successfully - don't wait for full cleanup in yolo mode
|
||||
// The task has completed successfully, so exit code 0
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
// Check for yolo timeout (5 minutes per action)
|
||||
if (Date.now() - yoloState.actionStartTime > YOLO_TIMEOUT_MS) {
|
||||
clearInterval(checkInterval)
|
||||
adapter.stopListening()
|
||||
reject(new Error("[YOLO] Action timed out after 5 minutes"))
|
||||
return
|
||||
}
|
||||
|
||||
// Check for failure state
|
||||
const failureCheck = isFailureState(messages)
|
||||
if (failureCheck.isFailure) {
|
||||
if (yoloState.lastFailedAction === failureCheck.actionKey) {
|
||||
yoloState.failureCount++
|
||||
} else {
|
||||
yoloState.lastFailedAction = failureCheck.actionKey
|
||||
yoloState.failureCount = 1
|
||||
}
|
||||
|
||||
if (yoloState.failureCount >= YOLO_MAX_FAILURES) {
|
||||
clearInterval(checkInterval)
|
||||
adapter.stopListening()
|
||||
reject(new Error(`[YOLO] Same action failed ${YOLO_MAX_FAILURES} times`))
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-retry
|
||||
formatter.warn(`[YOLO] Action failed (attempt ${yoloState.failureCount}/${YOLO_MAX_FAILURES}), retrying...`)
|
||||
yoloState.actionStartTime = Date.now()
|
||||
await controller.task.handleWebviewAskResponse("yesButtonClicked")
|
||||
return
|
||||
} else if (failureCheck.actionKey === null) {
|
||||
// Reset failure tracking on non-failure state
|
||||
yoloState.failureCount = 0
|
||||
yoloState.lastFailedAction = null
|
||||
}
|
||||
|
||||
// Check for pending input and auto-respond
|
||||
const pendingState = checkForPendingInput(messages)
|
||||
|
||||
if (pendingState.awaitingApproval) {
|
||||
yoloState.actionStartTime = Date.now()
|
||||
await controller.task.handleWebviewAskResponse("yesButtonClicked")
|
||||
return
|
||||
}
|
||||
|
||||
if (pendingState.awaitingInput) {
|
||||
yoloState.actionStartTime = Date.now()
|
||||
await controller.task.handleWebviewAskResponse("messageResponse", "proceed")
|
||||
return
|
||||
}
|
||||
|
||||
// Continue waiting for next state
|
||||
return
|
||||
}
|
||||
|
||||
// Normal mode: Check if task completed or awaiting response
|
||||
if (isAwaitingResponse(messages)) {
|
||||
clearInterval(checkInterval)
|
||||
adapter.stopListening()
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
// Check for task completion (no task or task finished)
|
||||
if (!controller.task) {
|
||||
clearInterval(checkInterval)
|
||||
adapter.stopListening()
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
// Check timeout
|
||||
if (Date.now() - startTime > timeoutMs) {
|
||||
clearInterval(checkInterval)
|
||||
adapter.stopListening()
|
||||
reject(new Error("Task timed out waiting for response"))
|
||||
}
|
||||
}, 100)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect multiple option values into an array
|
||||
* Used for -f and -i options that can be specified multiple times
|
||||
*/
|
||||
function collectOption(value: string, previous: string[]): string[] {
|
||||
return previous.concat([value])
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the task send command
|
||||
*/
|
||||
export function createTaskSendCommand(config: CliConfig, logger: Logger, formatter: OutputFormatter): Command {
|
||||
const sendCommand = new Command("send")
|
||||
.alias("s")
|
||||
.description("Send a message to the current task using embedded Controller")
|
||||
.argument("[message]", "Message to send (reads from stdin if not provided)")
|
||||
.option("-t, --task <id>", "Target task ID (starts new task if not specified)")
|
||||
.option("-a, --approve", "Approve a proposed action", false)
|
||||
.option("-d, --deny", "Deny a proposed action", false)
|
||||
.option("-f, --file <path>", "Attach file to message (can be repeated)", collectOption, [])
|
||||
.option("-i, --image <path>", "Attach image to message (can be repeated)", collectOption, [])
|
||||
.option("-y, --yolo", "Enable autonomous mode (no confirmations)", false)
|
||||
.option("--no-interactive", "Same as --yolo")
|
||||
.option("-m, --mode <mode>", "Switch to mode: act or plan")
|
||||
.option("-w, --wait", "Wait for task to complete or await input", false)
|
||||
.option("--timeout <ms>", "Timeout in milliseconds when using --wait (default: 300000)")
|
||||
.action(async (messageArg: string | undefined, options) => {
|
||||
logger.debug("Task send command called", { messageArg, options })
|
||||
|
||||
try {
|
||||
// Validate mutual exclusivity of approve/deny
|
||||
if (options.approve && options.deny) {
|
||||
throw new Error("Cannot use both --approve and --deny options")
|
||||
}
|
||||
|
||||
// Validate mode if provided
|
||||
const mode = validateMode(options.mode)
|
||||
|
||||
// Process explicit file and image attachments from CLI options
|
||||
const cwd = process.cwd()
|
||||
let explicitFiles: string[] = []
|
||||
let explicitImages: string[] = []
|
||||
|
||||
// Process -f/--file options (can be files or images, auto-detected)
|
||||
if (options.file && options.file.length > 0) {
|
||||
const processed = processExplicitFiles(options.file, cwd)
|
||||
explicitFiles = processed.files
|
||||
explicitImages = processed.images
|
||||
}
|
||||
|
||||
// Process -i/--image options (must be images)
|
||||
if (options.image && options.image.length > 0) {
|
||||
const images = processExplicitImages(options.image, cwd)
|
||||
explicitImages = explicitImages.concat(images)
|
||||
}
|
||||
|
||||
// Initialize embedded controller
|
||||
const controller = await getEmbeddedController(logger, config.configDir)
|
||||
|
||||
// Handle mode switch
|
||||
if (mode) {
|
||||
await controller.togglePlanActMode(mode)
|
||||
formatter.info(`Switched to ${mode} mode`)
|
||||
}
|
||||
|
||||
// Set up YOLO mode in Cline core settings if --yolo flag is set
|
||||
// This enables the core to:
|
||||
// 1. Modify system prompt to not ask followup questions
|
||||
// 2. Auto-switch from Plan to Act mode
|
||||
// 3. Auto-approve tools based on auto-approval settings
|
||||
if (options.yolo) {
|
||||
controller.stateManager.setGlobalState("yoloModeToggled", true)
|
||||
// Increase mistake limit for autonomous operation (matches Go CLI behavior)
|
||||
controller.stateManager.setGlobalState("maxConsecutiveMistakes", 6)
|
||||
// Ensure we're in Act mode for autonomous execution (unless user explicitly chose a mode)
|
||||
if (!mode) {
|
||||
await controller.togglePlanActMode("act")
|
||||
}
|
||||
}
|
||||
|
||||
// Handle approve/deny for existing task
|
||||
if (options.approve || options.deny) {
|
||||
if (!controller.task) {
|
||||
throw new Error("No active task to approve/deny")
|
||||
}
|
||||
|
||||
const response = options.approve ? "yesButtonClicked" : "noButtonClicked"
|
||||
await controller.task.handleWebviewAskResponse(response)
|
||||
formatter.success(options.approve ? "Action approved" : "Action denied")
|
||||
|
||||
if (options.wait || options.yolo) {
|
||||
await waitForTaskResponse(controller, formatter, parseInt(options.timeout) || 300000, options.yolo)
|
||||
}
|
||||
|
||||
// Output result in JSON format if requested
|
||||
if (config.outputFormat === "json") {
|
||||
const state = await controller.getStateToPostToWebview()
|
||||
formatter.raw(
|
||||
JSON.stringify(
|
||||
{
|
||||
taskId: controller.task?.taskId,
|
||||
action: options.approve ? "approved" : "denied",
|
||||
messageCount: state.clineMessages?.length || 0,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
await disposeEmbeddedController(logger)
|
||||
return
|
||||
}
|
||||
|
||||
// Determine message content
|
||||
let message = messageArg
|
||||
|
||||
// Try to read from stdin if no message argument
|
||||
if (!message) {
|
||||
const stdinMessage = await readStdin()
|
||||
if (stdinMessage) {
|
||||
message = stdinMessage
|
||||
}
|
||||
}
|
||||
|
||||
if (!message) {
|
||||
throw new Error("No message provided. Use argument or pipe via stdin")
|
||||
}
|
||||
|
||||
// Parse @path references from the message
|
||||
const parsedPaths = parseAtPaths(message, cwd)
|
||||
|
||||
// Show warnings for any files that couldn't be processed (non-fatal for @paths)
|
||||
for (const warning of parsedPaths.warnings) {
|
||||
formatter.warn(warning)
|
||||
}
|
||||
|
||||
// Use cleaned message (with @paths removed)
|
||||
const cleanedMessage = parsedPaths.cleanedMessage
|
||||
|
||||
// Combine explicit attachments with @path attachments
|
||||
const allFiles = [...explicitFiles, ...parsedPaths.files]
|
||||
const allImages = [...explicitImages, ...parsedPaths.images]
|
||||
|
||||
// Log attachment info
|
||||
if (allFiles.length > 0) {
|
||||
formatter.info(`Attaching ${allFiles.length} file(s)`)
|
||||
}
|
||||
if (allImages.length > 0) {
|
||||
formatter.info(`Attaching ${allImages.length} image(s)`)
|
||||
}
|
||||
|
||||
// Start or continue task
|
||||
let taskId: string | undefined
|
||||
|
||||
if (options.task) {
|
||||
// Resume existing task
|
||||
const history = await controller.getTaskWithId(options.task)
|
||||
if (!history) {
|
||||
throw new Error(`Task not found: ${options.task}`)
|
||||
}
|
||||
taskId = await controller.initTask(undefined, undefined, undefined, history.historyItem)
|
||||
formatter.info(`Resumed task: ${taskId}`)
|
||||
|
||||
// Send the message with attachments
|
||||
if (controller.task) {
|
||||
await controller.task.handleWebviewAskResponse(
|
||||
"messageResponse",
|
||||
cleanedMessage,
|
||||
allImages.length > 0 ? allImages : undefined,
|
||||
allFiles.length > 0 ? allFiles : undefined,
|
||||
)
|
||||
}
|
||||
} else if (controller.task) {
|
||||
// Send to existing active task
|
||||
taskId = controller.task.taskId
|
||||
await controller.task.handleWebviewAskResponse(
|
||||
"messageResponse",
|
||||
cleanedMessage,
|
||||
allImages.length > 0 ? allImages : undefined,
|
||||
allFiles.length > 0 ? allFiles : undefined,
|
||||
)
|
||||
formatter.info(`Message sent to task ${taskId.slice(0, 8)}`)
|
||||
} else {
|
||||
// Start new task with the message as prompt
|
||||
taskId = await controller.initTask(
|
||||
cleanedMessage,
|
||||
allImages.length > 0 ? allImages : undefined,
|
||||
allFiles.length > 0 ? allFiles : undefined,
|
||||
)
|
||||
formatter.info(`Started new task: ${taskId}`)
|
||||
}
|
||||
|
||||
// Wait for response if requested (yolo mode always waits for completion)
|
||||
if (options.wait || options.yolo) {
|
||||
if (options.yolo) {
|
||||
formatter.info("[YOLO] Autonomous mode - running until completion...")
|
||||
} else {
|
||||
formatter.info("Waiting for task response...")
|
||||
}
|
||||
await waitForTaskResponse(controller, formatter, parseInt(options.timeout) || 300000, options.yolo)
|
||||
}
|
||||
|
||||
// Output result in JSON format if requested
|
||||
if (config.outputFormat === "json") {
|
||||
const state = await controller.getStateToPostToWebview()
|
||||
formatter.raw(
|
||||
JSON.stringify(
|
||||
{
|
||||
taskId,
|
||||
message,
|
||||
messageCount: state.clineMessages?.length || 0,
|
||||
mode: state.mode,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Reset yolo mode settings if they were enabled for this command
|
||||
if (options.yolo) {
|
||||
controller.stateManager.setGlobalState("yoloModeToggled", false)
|
||||
controller.stateManager.setGlobalState("maxConsecutiveMistakes", 3)
|
||||
}
|
||||
|
||||
await disposeEmbeddedController(logger)
|
||||
} catch (error) {
|
||||
formatter.error((error as Error).message)
|
||||
// Reset yolo mode settings on error too
|
||||
if (options.yolo) {
|
||||
const controller = getControllerIfInitialized()
|
||||
if (controller) {
|
||||
controller.stateManager.setGlobalState("yoloModeToggled", false)
|
||||
controller.stateManager.setGlobalState("maxConsecutiveMistakes", 3)
|
||||
}
|
||||
}
|
||||
await disposeEmbeddedController(logger)
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
|
||||
return sendCommand
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* Task view command - view conversation history using embedded Controller
|
||||
*
|
||||
* This command displays task conversation history from the embedded
|
||||
* Controller, with options for real-time streaming and following.
|
||||
*/
|
||||
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { Command } from "commander"
|
||||
import { CliWebviewAdapter } from "../../core/cli-webview-adapter.js"
|
||||
import { disposeEmbeddedController, getEmbeddedController } from "../../core/embedded-controller.js"
|
||||
import type { OutputFormatter } from "../../core/output/types.js"
|
||||
import type { CliConfig } from "../../types/config.js"
|
||||
import type { Logger } from "../../types/logger.js"
|
||||
|
||||
/**
|
||||
* Format a ClineMessage for display
|
||||
*/
|
||||
function formatMessageSummary(msg: ClineMessage): string {
|
||||
const timestamp = new Date(msg.ts).toLocaleTimeString()
|
||||
const type = msg.type.toUpperCase()
|
||||
|
||||
let subtype = ""
|
||||
if (msg.say) {
|
||||
subtype = ` [${msg.say}]`
|
||||
} else if (msg.ask) {
|
||||
subtype = ` [${msg.ask}]`
|
||||
}
|
||||
|
||||
// Truncate long messages
|
||||
let content = msg.text || ""
|
||||
if (content.length > 100) {
|
||||
content = content.slice(0, 97) + "..."
|
||||
}
|
||||
|
||||
// Handle special message types
|
||||
if (msg.say === "api_req_started" || msg.say === "api_req_finished") {
|
||||
try {
|
||||
const info = JSON.parse(msg.text || "{}")
|
||||
if (info.tokensIn || info.tokensOut) {
|
||||
content = `tokens: ${info.tokensIn || 0} in / ${info.tokensOut || 0} out`
|
||||
}
|
||||
} catch {
|
||||
// Keep original content
|
||||
}
|
||||
}
|
||||
|
||||
return `[${timestamp}] ${type}${subtype}: ${content.replace(/\n/g, " ")}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if task is complete or awaiting input
|
||||
*/
|
||||
function isTaskComplete(messages: ClineMessage[]): boolean {
|
||||
if (messages.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
|
||||
// Task is complete if last message is completion_result
|
||||
if (lastMessage.ask === "completion_result" || lastMessage.say === "completion_result") {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep for a given number of milliseconds
|
||||
*/
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the task view command
|
||||
*/
|
||||
export function createTaskViewCommand(config: CliConfig, logger: Logger, formatter: OutputFormatter): Command {
|
||||
const viewCommand = new Command("view")
|
||||
.alias("v")
|
||||
.description("View task conversation history using embedded Controller")
|
||||
.argument("[taskId]", "Task ID to view (defaults to current or most recent task)")
|
||||
.option("-f, --follow", "Stream updates in real-time", false)
|
||||
.option("-c, --follow-complete", "Follow until task completion", false)
|
||||
.option("-n, --last <count>", "Show only last N messages")
|
||||
.option("--since <timestamp>", "Show messages since timestamp (Unix ms)")
|
||||
.option("-r, --raw", "Show raw message data (useful for debugging)", false)
|
||||
.action(async (taskIdArg: string | undefined, options) => {
|
||||
logger.debug("Task view command called", { taskIdArg, options })
|
||||
|
||||
try {
|
||||
// Initialize embedded controller
|
||||
const controller = await getEmbeddedController(logger, config.configDir)
|
||||
|
||||
// Get task history to find the task
|
||||
const state = await controller.getStateToPostToWebview()
|
||||
const taskHistory = state.taskHistory || []
|
||||
|
||||
// Determine which task to view
|
||||
let taskId = taskIdArg
|
||||
let historyItem = null
|
||||
|
||||
if (taskId) {
|
||||
// Find task by ID (support partial ID match)
|
||||
historyItem = taskHistory.find((t) => t.id === taskId || t.id.startsWith(taskId || ""))
|
||||
if (!historyItem) {
|
||||
throw new Error(`Task not found: ${taskId}`)
|
||||
}
|
||||
taskId = historyItem.id
|
||||
} else {
|
||||
// Use current task or most recent
|
||||
if (controller.task) {
|
||||
taskId = controller.task.taskId
|
||||
historyItem = taskHistory.find((t) => t.id === taskId)
|
||||
} else if (taskHistory.length > 0) {
|
||||
historyItem = taskHistory[0] // Most recent
|
||||
taskId = historyItem.id
|
||||
} else {
|
||||
throw new Error("No tasks found. Create a task with 'cline task new'")
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize task if not already active
|
||||
if (!controller.task || controller.task.taskId !== taskId) {
|
||||
if (historyItem) {
|
||||
const taskData = await controller.getTaskWithId(taskId)
|
||||
await controller.initTask(undefined, undefined, undefined, taskData.historyItem)
|
||||
}
|
||||
}
|
||||
|
||||
// Display task info header
|
||||
formatter.info(`\nTask: ${taskId}`)
|
||||
if (historyItem) {
|
||||
formatter.info(`Status: ${historyItem.size ? "has content" : "empty"}`)
|
||||
if (historyItem.task) {
|
||||
const promptPreview = historyItem.task.slice(0, 60) + (historyItem.task.length > 60 ? "..." : "")
|
||||
formatter.info(`Prompt: ${promptPreview}`)
|
||||
}
|
||||
}
|
||||
formatter.raw("─".repeat(60))
|
||||
|
||||
// Get messages
|
||||
let messages = controller.task?.messageStateHandler.getClineMessages() || []
|
||||
|
||||
// Filter by timestamp if provided
|
||||
if (options.since) {
|
||||
const sinceTs = parseInt(options.since, 10)
|
||||
if (isNaN(sinceTs)) {
|
||||
throw new Error(`Invalid timestamp: ${options.since}`)
|
||||
}
|
||||
messages = messages.filter((m) => m.ts > sinceTs)
|
||||
}
|
||||
|
||||
// Limit to last N messages if specified
|
||||
if (options.last) {
|
||||
const count = parseInt(options.last, 10)
|
||||
if (isNaN(count) || count < 1) {
|
||||
throw new Error(`Invalid count: ${options.last}`)
|
||||
}
|
||||
messages = messages.slice(-count)
|
||||
}
|
||||
|
||||
// Display messages
|
||||
if (messages.length === 0) {
|
||||
formatter.info("No messages yet")
|
||||
} else {
|
||||
if (options.raw) {
|
||||
// Raw JSON output
|
||||
for (const msg of messages) {
|
||||
formatter.raw(JSON.stringify(msg, null, 2))
|
||||
formatter.raw("")
|
||||
}
|
||||
} else {
|
||||
// Formatted output using the adapter
|
||||
const adapter = new CliWebviewAdapter(controller, formatter)
|
||||
for (const msg of messages) {
|
||||
adapter.outputMessage(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// JSON output for non-follow mode
|
||||
if (config.outputFormat === "json" && !options.follow && !options.followComplete) {
|
||||
formatter.raw(
|
||||
JSON.stringify(
|
||||
{
|
||||
taskId,
|
||||
prompt: historyItem?.task,
|
||||
messageCount: messages.length,
|
||||
messages: options.raw ? messages : messages.map(formatMessageSummary),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
await disposeEmbeddedController(logger)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle follow mode
|
||||
if (options.follow || options.followComplete) {
|
||||
formatter.raw("")
|
||||
formatter.info("Watching for new messages... (Ctrl+C to stop)")
|
||||
formatter.raw("─".repeat(60))
|
||||
|
||||
let isRunning = true
|
||||
let lastMessageCount = messages.length
|
||||
|
||||
// Create adapter for streaming output
|
||||
const adapter = new CliWebviewAdapter(controller, formatter)
|
||||
|
||||
// Handle Ctrl+C gracefully
|
||||
const cleanup = async () => {
|
||||
isRunning = false
|
||||
formatter.raw("")
|
||||
formatter.info("Stopped watching")
|
||||
adapter.stopListening()
|
||||
await disposeEmbeddedController(logger)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
process.on("SIGINT", cleanup)
|
||||
process.on("SIGTERM", cleanup)
|
||||
|
||||
// Poll for new messages
|
||||
const pollInterval = 100 // ms
|
||||
|
||||
while (isRunning) {
|
||||
await sleep(pollInterval)
|
||||
|
||||
// Get current messages
|
||||
const currentMessages = controller.task?.messageStateHandler.getClineMessages() || []
|
||||
|
||||
// Output new messages
|
||||
if (currentMessages.length > lastMessageCount) {
|
||||
const newMessages = currentMessages.slice(lastMessageCount)
|
||||
for (const msg of newMessages) {
|
||||
adapter.outputMessage(msg)
|
||||
}
|
||||
lastMessageCount = currentMessages.length
|
||||
}
|
||||
|
||||
// Check if task completed (for --follow-complete)
|
||||
if (options.followComplete && isTaskComplete(currentMessages)) {
|
||||
formatter.raw("")
|
||||
formatter.info("Task completed")
|
||||
break
|
||||
}
|
||||
|
||||
// Check if task was cleared
|
||||
if (!controller.task) {
|
||||
formatter.warn("Task was cleared")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Remove listeners
|
||||
process.removeListener("SIGINT", cleanup)
|
||||
process.removeListener("SIGTERM", cleanup)
|
||||
}
|
||||
|
||||
await disposeEmbeddedController(logger)
|
||||
} catch (error) {
|
||||
formatter.error((error as Error).message)
|
||||
await disposeEmbeddedController(logger)
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
|
||||
return viewCommand
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Command } from "commander"
|
||||
import type { CliConfig } from "../types/config.js"
|
||||
import type { Logger } from "../types/logger.js"
|
||||
|
||||
// Version is injected at build time via esbuild define
|
||||
declare const __CLINE_VERSION__: string
|
||||
|
||||
/**
|
||||
* Get the Cline version from the build-time injected value
|
||||
*/
|
||||
export function getVersion(): string {
|
||||
return __CLINE_VERSION__
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the version command - displays the Cline version
|
||||
*/
|
||||
export function runVersionCommand(config: CliConfig, logger: Logger): void {
|
||||
const version = getVersion()
|
||||
logger.debug(`Displaying version: ${version}`)
|
||||
console.log(`cline ${version}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the version subcommand
|
||||
*/
|
||||
export function createVersionCommand(config: CliConfig, logger: Logger): Command {
|
||||
return new Command("version").description("Display the Cline version").action(() => {
|
||||
runVersionCommand(config, logger)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* API Provider definitions for authentication
|
||||
*/
|
||||
|
||||
/**
|
||||
* Provider information for authentication
|
||||
*/
|
||||
export interface ProviderInfo {
|
||||
/** Provider identifier */
|
||||
id: string
|
||||
/** Display name */
|
||||
name: string
|
||||
/** Description for the interactive wizard */
|
||||
description: string
|
||||
/** Whether this provider requires an API key */
|
||||
requiresApiKey: boolean
|
||||
/** Environment variable name for API key (if any) */
|
||||
envVar?: string
|
||||
/** URL to get an API key */
|
||||
keyUrl?: string
|
||||
/** Whether this provider supports OAuth */
|
||||
supportsOAuth?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Available API providers
|
||||
*/
|
||||
export const PROVIDERS: ProviderInfo[] = [
|
||||
{
|
||||
id: "anthropic",
|
||||
name: "Anthropic",
|
||||
description: "Direct access to Claude models via Anthropic API",
|
||||
requiresApiKey: true,
|
||||
envVar: "ANTHROPIC_API_KEY",
|
||||
keyUrl: "https://console.anthropic.com/settings/keys",
|
||||
},
|
||||
{
|
||||
id: "openrouter",
|
||||
name: "OpenRouter",
|
||||
description: "Access multiple AI providers through a single API",
|
||||
requiresApiKey: true,
|
||||
envVar: "OPENROUTER_API_KEY",
|
||||
keyUrl: "https://openrouter.ai/keys",
|
||||
},
|
||||
{
|
||||
id: "openai",
|
||||
name: "OpenAI",
|
||||
description: "Access to GPT models via OpenAI API",
|
||||
requiresApiKey: true,
|
||||
envVar: "OPENAI_API_KEY",
|
||||
keyUrl: "https://platform.openai.com/api-keys",
|
||||
},
|
||||
{
|
||||
id: "bedrock",
|
||||
name: "AWS Bedrock",
|
||||
description: "AWS Bedrock with Claude and other models (uses AWS credentials from environment or ~/.aws/credentials)",
|
||||
requiresApiKey: false,
|
||||
},
|
||||
{
|
||||
id: "gemini",
|
||||
name: "Google Gemini",
|
||||
description: "Access to Gemini models via Google AI API",
|
||||
requiresApiKey: true,
|
||||
envVar: "GOOGLE_API_KEY",
|
||||
keyUrl: "https://aistudio.google.com/app/apikey",
|
||||
},
|
||||
{
|
||||
id: "ollama",
|
||||
name: "Ollama",
|
||||
description: "Local models via Ollama (no API key required)",
|
||||
requiresApiKey: false,
|
||||
},
|
||||
{
|
||||
id: "lmstudio",
|
||||
name: "LM Studio",
|
||||
description: "Local models via LM Studio (no API key required)",
|
||||
requiresApiKey: false,
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Get provider by ID
|
||||
*/
|
||||
export function getProviderById(id: string): ProviderInfo | undefined {
|
||||
return PROVIDERS.find((p) => p.id === id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all provider IDs
|
||||
*/
|
||||
export function getProviderIds(): string[] {
|
||||
return PROVIDERS.map((p) => p.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a provider ID is valid
|
||||
*/
|
||||
export function isValidProviderId(id: string): boolean {
|
||||
return PROVIDERS.some((p) => p.id === id)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Secrets storage for API keys
|
||||
* Stores API keys in ~/.cline/secrets.json with restricted permissions
|
||||
*/
|
||||
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { getDefaultConfigDir } from "../config.js"
|
||||
|
||||
/**
|
||||
* Stored secrets schema
|
||||
*/
|
||||
export interface StoredSecrets {
|
||||
[providerId: string]: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Secrets storage class
|
||||
*/
|
||||
export class SecretsStorage {
|
||||
private secretsPath: string
|
||||
private configDir: string
|
||||
|
||||
constructor(configDir?: string) {
|
||||
this.configDir = configDir || getDefaultConfigDir()
|
||||
this.secretsPath = path.join(this.configDir, "secrets.json")
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the config directory exists with proper permissions
|
||||
*/
|
||||
private ensureConfigDir(): void {
|
||||
if (!fs.existsSync(this.configDir)) {
|
||||
fs.mkdirSync(this.configDir, { recursive: true, mode: 0o700 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load secrets from disk
|
||||
*/
|
||||
load(): StoredSecrets {
|
||||
try {
|
||||
if (fs.existsSync(this.secretsPath)) {
|
||||
const content = fs.readFileSync(this.secretsPath, "utf-8")
|
||||
return JSON.parse(content) as StoredSecrets
|
||||
}
|
||||
} catch {
|
||||
// Return empty on error
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save secrets to disk with restricted permissions
|
||||
*/
|
||||
save(secrets: StoredSecrets): void {
|
||||
this.ensureConfigDir()
|
||||
fs.writeFileSync(this.secretsPath, JSON.stringify(secrets, null, 2), {
|
||||
mode: 0o600, // Read/write for owner only
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get API key for a provider
|
||||
*/
|
||||
getApiKey(providerId: string): string | undefined {
|
||||
const secrets = this.load()
|
||||
return secrets[providerId]
|
||||
}
|
||||
|
||||
/**
|
||||
* Set API key for a provider
|
||||
*/
|
||||
setApiKey(providerId: string, apiKey: string): void {
|
||||
const secrets = this.load()
|
||||
secrets[providerId] = apiKey
|
||||
this.save(secrets)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete API key for a provider
|
||||
*/
|
||||
deleteApiKey(providerId: string): boolean {
|
||||
const secrets = this.load()
|
||||
if (providerId in secrets) {
|
||||
delete secrets[providerId]
|
||||
this.save(secrets)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* List all providers with stored keys
|
||||
*/
|
||||
listProviders(): string[] {
|
||||
const secrets = this.load()
|
||||
return Object.keys(secrets)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a provider has a stored key
|
||||
*/
|
||||
hasApiKey(providerId: string): boolean {
|
||||
const secrets = this.load()
|
||||
return providerId in secrets
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the secrets file
|
||||
*/
|
||||
getSecretsPath(): string {
|
||||
return this.secretsPath
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all secrets
|
||||
*/
|
||||
clear(): void {
|
||||
this.save({})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a secrets storage instance
|
||||
*/
|
||||
export function createSecretsStorage(configDir?: string): SecretsStorage {
|
||||
return new SecretsStorage(configDir)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask an API key for display (show first/last 4 chars)
|
||||
*/
|
||||
export function maskApiKey(key: string): string {
|
||||
if (key.length <= 8) {
|
||||
return "****"
|
||||
}
|
||||
return `${key.slice(0, 4)}...${key.slice(-4)}`
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* CLI Webview Adapter
|
||||
*
|
||||
* This module bridges the Controller's state updates with terminal output.
|
||||
* It coordinates between state subscriptions and message renderers to format
|
||||
* ClineMessages for display in the terminal.
|
||||
*
|
||||
* Architecture:
|
||||
* - StateSubscriber: Handles gRPC subscriptions and message tracking
|
||||
* - SayMessageRenderer: Renders "say" type messages
|
||||
* - AskMessageRenderer: Renders "ask" type messages
|
||||
* - ToolRenderer: Renders tool operations and approvals
|
||||
* - BrowserActionRenderer: Renders browser actions
|
||||
*/
|
||||
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import type { Controller } from "@/core/controller"
|
||||
import {
|
||||
AskMessageRenderer,
|
||||
BrowserActionRenderer,
|
||||
type RenderContext,
|
||||
SayMessageRenderer,
|
||||
ToolRenderer,
|
||||
} from "./message-rendering/index.js"
|
||||
import type { OutputFormatter } from "./output/types.js"
|
||||
import { type ActivitySpinner, createActivitySpinner } from "./spinner.js"
|
||||
import { type StateChangeHandler, StateSubscriber } from "./state-subscription/index.js"
|
||||
|
||||
// Re-export for consumers
|
||||
export type { StateChangeHandler } from "./state-subscription/index.js"
|
||||
|
||||
/**
|
||||
* CLI Webview Adapter class
|
||||
*
|
||||
* Subscribes to Controller state updates and outputs messages to the terminal.
|
||||
* Acts as a coordinator between state subscriptions and message rendering.
|
||||
*/
|
||||
export class CliWebviewAdapter {
|
||||
private stateSubscriber: StateSubscriber
|
||||
private sayRenderer: SayMessageRenderer
|
||||
private askRenderer: AskMessageRenderer
|
||||
private _currentOptions: string[] = []
|
||||
private activitySpinner: ActivitySpinner
|
||||
private isProcessing = false
|
||||
private onStateChange?: StateChangeHandler
|
||||
|
||||
constructor(
|
||||
private controller: Controller,
|
||||
private formatter: OutputFormatter,
|
||||
) {
|
||||
// Create activity spinner that shows after 1 second of inactivity
|
||||
this.activitySpinner = createActivitySpinner({
|
||||
message: "Working hard...",
|
||||
delayMs: 1000,
|
||||
})
|
||||
|
||||
// Create render context for all renderers
|
||||
const renderContext: RenderContext = {
|
||||
formatter: this.formatter,
|
||||
getMessages: () => this.getMessages(),
|
||||
setCurrentOptions: (options: string[]) => {
|
||||
this._currentOptions = options
|
||||
},
|
||||
}
|
||||
|
||||
// Create renderers
|
||||
const toolRenderer = new ToolRenderer(renderContext)
|
||||
const browserRenderer = new BrowserActionRenderer(renderContext)
|
||||
this.sayRenderer = new SayMessageRenderer(renderContext, toolRenderer, browserRenderer)
|
||||
this.askRenderer = new AskMessageRenderer(renderContext, toolRenderer)
|
||||
|
||||
// Create state subscriber
|
||||
this.stateSubscriber = new StateSubscriber(this.controller, {
|
||||
onStateChange: (messages) => this.onStateChange?.(messages),
|
||||
onCompleteMessage: (msg) => this.outputMessage(msg),
|
||||
getMessages: () => this.getMessages(),
|
||||
onActivity: () => {
|
||||
if (this.isProcessing) {
|
||||
this.activitySpinner.reportActivity()
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current options for numbered selection
|
||||
*/
|
||||
get currentOptions(): string[] {
|
||||
return this._currentOptions
|
||||
}
|
||||
|
||||
/**
|
||||
* Set whether the AI is currently processing
|
||||
*
|
||||
* When processing is true, the spinner will start monitoring for inactivity.
|
||||
* When processing is false (e.g., waiting for user input), the spinner is disabled.
|
||||
*/
|
||||
setProcessing(processing: boolean): void {
|
||||
this.isProcessing = processing
|
||||
this.activitySpinner.setEnabled(processing)
|
||||
|
||||
if (processing) {
|
||||
// Start monitoring for inactivity
|
||||
this.activitySpinner.startMonitoring("Processing...")
|
||||
} else {
|
||||
// Stop spinner when not processing
|
||||
this.activitySpinner.stop()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start listening for state updates
|
||||
*
|
||||
* @param onStateChange - Optional callback for raw state changes
|
||||
*/
|
||||
startListening(onStateChange?: StateChangeHandler): void {
|
||||
this.onStateChange = onStateChange
|
||||
this.stateSubscriber.start()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop listening for state updates
|
||||
*/
|
||||
stopListening(): void {
|
||||
this.stateSubscriber.stop()
|
||||
this.activitySpinner.stop()
|
||||
}
|
||||
|
||||
/**
|
||||
* Output a ClineMessage to the terminal
|
||||
*/
|
||||
outputMessage(msg: ClineMessage): void {
|
||||
if (msg.type === "say") {
|
||||
this.sayRenderer.render(msg)
|
||||
} else if (msg.type === "ask") {
|
||||
this.askRenderer.render(msg)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current messages from the Controller
|
||||
*/
|
||||
getMessages(): ClineMessage[] {
|
||||
return this.controller.task?.messageStateHandler.getClineMessages() || []
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the message counter (useful when starting a new task)
|
||||
*/
|
||||
resetMessageCounter(): void {
|
||||
this.stateSubscriber.reset()
|
||||
}
|
||||
|
||||
/**
|
||||
* Output all current messages (useful for initial display)
|
||||
*/
|
||||
outputAllMessages(): void {
|
||||
const messages = this.getMessages()
|
||||
for (const msg of messages) {
|
||||
if (!msg.partial && !this.stateSubscriber.hasBeenPrinted(msg.ts)) {
|
||||
this.outputMessage(msg)
|
||||
this.stateSubscriber.markPrinted(msg.ts)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import type { CliConfig, PartialCliConfig } from "../types/config.js"
|
||||
import { getDefaultFormat } from "./output/index.js"
|
||||
|
||||
/**
|
||||
* Get the default Cline configuration directory
|
||||
* @returns Path to ~/.cline
|
||||
*/
|
||||
export function getDefaultConfigDir(): string {
|
||||
return path.join(os.homedir(), ".cline")
|
||||
}
|
||||
|
||||
/**
|
||||
* Default CLI configuration values
|
||||
*/
|
||||
export const DEFAULT_CLI_CONFIG: CliConfig = {
|
||||
verbose: false,
|
||||
configDir: getDefaultConfigDir(),
|
||||
outputFormat: getDefaultFormat(),
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a CLI configuration by merging defaults with provided options
|
||||
*/
|
||||
export function createConfig(options: PartialCliConfig = {}): CliConfig {
|
||||
return {
|
||||
...DEFAULT_CLI_CONFIG,
|
||||
...options,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Console output filtering for CLI mode
|
||||
*
|
||||
* Intercepts all console methods (log, info, debug, warn, error) to suppress
|
||||
* noisy operational messages unless verbose mode is enabled.
|
||||
*
|
||||
* This must be called EARLY in CLI startup, before any other code runs,
|
||||
* to ensure all console output is filtered.
|
||||
*/
|
||||
|
||||
// Store original console methods for restoration
|
||||
const originalConsole = {
|
||||
log: console.log,
|
||||
info: console.info,
|
||||
debug: console.debug,
|
||||
warn: console.warn,
|
||||
error: console.error,
|
||||
}
|
||||
|
||||
// Patterns that indicate noisy operational output
|
||||
export const NOISE_PATTERNS = [
|
||||
// Telemetry & Feature Flags
|
||||
"Telemetry distinct ID",
|
||||
"Changing telemetry ID",
|
||||
"TelemetryService",
|
||||
"TelemetryProviderFactory",
|
||||
"NoOpTelemetryProvider",
|
||||
"NoOpFeatureFlagsProvider",
|
||||
"NoOpErrorProvider",
|
||||
"identifyUser",
|
||||
|
||||
// Storage & Migration
|
||||
"Storage Migration",
|
||||
"FileContextTracker",
|
||||
|
||||
// Checkpoints & Git Operations
|
||||
"CheckpointTracker",
|
||||
"checkpoint",
|
||||
"Checkpoint",
|
||||
"Repository ID",
|
||||
"cwdHash",
|
||||
"shadow git",
|
||||
"Shadow git",
|
||||
"Getting diff count between commits",
|
||||
"diff count",
|
||||
|
||||
// Task & Lock Management
|
||||
"Lock manager not available",
|
||||
"Task lock",
|
||||
"Skipping Checkpoints lock",
|
||||
"Todo file watcher",
|
||||
"[Task",
|
||||
|
||||
// Workspace & Terminal
|
||||
"WorkspaceManager",
|
||||
"TerminalManager",
|
||||
"StandaloneTerminalRegistry",
|
||||
"StandaloneTerminal",
|
||||
|
||||
// Focus Chain
|
||||
"focus chain",
|
||||
"Focus Chain",
|
||||
|
||||
// Server & Initialization
|
||||
"#bot.cline.server.ts",
|
||||
"instantiated",
|
||||
"for legacy",
|
||||
|
||||
// Registry
|
||||
"Registry health check",
|
||||
|
||||
// Debug markers
|
||||
"[DEBUG]",
|
||||
"[OTEL",
|
||||
|
||||
// MCP
|
||||
"[MCP",
|
||||
|
||||
// Component warnings that are not errors
|
||||
"Component '",
|
||||
"Warning: Component",
|
||||
|
||||
// Controller lifecycle (not actual errors)
|
||||
"Controller disposed",
|
||||
|
||||
"[INFO ] Executing command",
|
||||
]
|
||||
|
||||
/**
|
||||
* Check if a message should be suppressed based on noise patterns
|
||||
*/
|
||||
function shouldSuppress(args: unknown[]): boolean {
|
||||
const message = args.map(String).join(" ")
|
||||
return NOISE_PATTERNS.some((pattern) => message.includes(pattern))
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply console filtering to suppress noisy output
|
||||
*
|
||||
* @param verbose - If true, no filtering is applied (all output shown)
|
||||
*/
|
||||
export function applyConsoleFilter(verbose: boolean): void {
|
||||
if (verbose) {
|
||||
// In verbose mode, restore original methods (no filtering)
|
||||
restoreConsole()
|
||||
return
|
||||
}
|
||||
|
||||
// Replace console methods with filtered versions
|
||||
console.log = (...args: unknown[]) => {
|
||||
if (!shouldSuppress(args)) {
|
||||
originalConsole.log.apply(console, args)
|
||||
}
|
||||
}
|
||||
|
||||
console.info = (...args: unknown[]) => {
|
||||
if (!shouldSuppress(args)) {
|
||||
originalConsole.info.apply(console, args)
|
||||
}
|
||||
}
|
||||
|
||||
console.warn = (...args: unknown[]) => {
|
||||
if (!shouldSuppress(args)) {
|
||||
originalConsole.warn.apply(console, args)
|
||||
}
|
||||
}
|
||||
|
||||
console.error = (...args: unknown[]) => {
|
||||
if (!shouldSuppress(args)) {
|
||||
originalConsole.error.apply(console, args)
|
||||
}
|
||||
}
|
||||
|
||||
// Always suppress debug in non-verbose mode
|
||||
console.debug = () => {
|
||||
// No-op
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore original console methods
|
||||
*
|
||||
* Useful for testing or when verbose mode is toggled
|
||||
*/
|
||||
export function restoreConsole(): void {
|
||||
console.log = originalConsole.log
|
||||
console.info = originalConsole.info
|
||||
console.debug = originalConsole.debug
|
||||
console.warn = originalConsole.warn
|
||||
console.error = originalConsole.error
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Re-export the VSCode context initialization from the standalone module
|
||||
*
|
||||
* This reuses the existing implementation that creates a VSCode-like
|
||||
* ExtensionContext for standalone (non-VSCode) mode.
|
||||
*/
|
||||
export { initializeContext } from "@/standalone/vscode-context"
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Embedded Controller for CLI
|
||||
*
|
||||
* This module initializes a Cline Controller directly in the CLI process,
|
||||
* allowing CLI commands (chat, send, view) to interact with Cline's AI
|
||||
* without requiring a separate gRPC server.
|
||||
*/
|
||||
|
||||
import { initialize, tearDown } from "@/common"
|
||||
import { Controller } from "@/core/controller"
|
||||
import type { WebviewProvider } from "@/core/webview"
|
||||
import { initializeContext } from "@/standalone/vscode-context"
|
||||
import type { Logger } from "../types/logger.js"
|
||||
import { isHostProviderInitialized, setupHostProvider } from "./host-provider-setup.js"
|
||||
|
||||
// Singleton instance of the embedded controller
|
||||
let embeddedController: Controller | undefined
|
||||
let webviewProvider: WebviewProvider | undefined
|
||||
let initializationPromise: Promise<Controller> | undefined
|
||||
let isInitializing = false
|
||||
|
||||
/**
|
||||
* Get or create an embedded Controller instance for CLI usage
|
||||
*
|
||||
* This function is idempotent - calling it multiple times will return
|
||||
* the same Controller instance.
|
||||
*
|
||||
* @param logger - Logger instance for CLI output
|
||||
* @param configDir - Optional custom config directory (defaults to ~/.cline)
|
||||
* @returns Promise resolving to the Controller instance
|
||||
*/
|
||||
export async function getEmbeddedController(logger: Logger, configDir?: string): Promise<Controller> {
|
||||
// Return existing instance if available
|
||||
if (embeddedController) {
|
||||
return embeddedController
|
||||
}
|
||||
|
||||
// Return in-progress initialization if one exists
|
||||
if (initializationPromise) {
|
||||
return initializationPromise
|
||||
}
|
||||
|
||||
// Start new initialization
|
||||
initializationPromise = initializeEmbeddedController(logger, configDir)
|
||||
|
||||
try {
|
||||
embeddedController = await initializationPromise
|
||||
return embeddedController
|
||||
} catch (error) {
|
||||
// Clear the promise so we can retry
|
||||
initializationPromise = undefined
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the embedded Controller
|
||||
*
|
||||
* @param logger - Logger instance for CLI output
|
||||
* @param configDir - Optional custom config directory
|
||||
* @returns Promise resolving to the Controller instance
|
||||
*/
|
||||
async function initializeEmbeddedController(logger: Logger, configDir?: string): Promise<Controller> {
|
||||
if (isInitializing) {
|
||||
throw new Error("Controller initialization already in progress")
|
||||
}
|
||||
|
||||
isInitializing = true
|
||||
|
||||
try {
|
||||
logger.debug("Initializing embedded controller...")
|
||||
|
||||
// Initialize VSCode-like context with storage directories
|
||||
const { extensionContext, DATA_DIR, EXTENSION_DIR } = initializeContext(configDir)
|
||||
|
||||
logger.debug(`Using data directory: ${DATA_DIR}`)
|
||||
logger.debug(`Using extension directory: ${EXTENSION_DIR}`)
|
||||
|
||||
// Setup HostProvider if not already initialized
|
||||
if (!isHostProviderInitialized()) {
|
||||
setupHostProvider(extensionContext, EXTENSION_DIR, DATA_DIR, logger)
|
||||
logger.debug("HostProvider initialized")
|
||||
}
|
||||
|
||||
// Initialize the extension common components and get WebviewProvider
|
||||
webviewProvider = await initialize(extensionContext)
|
||||
|
||||
// The controller is available via the webviewProvider
|
||||
const controller = webviewProvider.controller
|
||||
|
||||
logger.debug("Embedded controller initialized successfully")
|
||||
|
||||
return controller
|
||||
} catch (error) {
|
||||
logger.error(`Failed to initialize embedded controller: ${error}`)
|
||||
throw error
|
||||
} finally {
|
||||
isInitializing = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current Controller instance without initializing
|
||||
*
|
||||
* @returns The Controller instance if initialized, undefined otherwise
|
||||
*/
|
||||
export function getControllerIfInitialized(): Controller | undefined {
|
||||
return embeddedController
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the embedded Controller is initialized
|
||||
*
|
||||
* @returns true if initialized, false otherwise
|
||||
*/
|
||||
export function isControllerInitialized(): boolean {
|
||||
return embeddedController !== undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose the embedded Controller and clean up resources
|
||||
*
|
||||
* This should be called when the CLI process exits to ensure
|
||||
* proper cleanup of resources.
|
||||
*
|
||||
* @param logger - Logger instance for output
|
||||
*/
|
||||
export async function disposeEmbeddedController(logger: Logger): Promise<void> {
|
||||
if (!embeddedController) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
logger.debug("Disposing embedded controller...")
|
||||
|
||||
// Dispose the controller
|
||||
await embeddedController.dispose()
|
||||
|
||||
// Tear down common services
|
||||
await tearDown()
|
||||
|
||||
embeddedController = undefined
|
||||
webviewProvider = undefined
|
||||
initializationPromise = undefined
|
||||
|
||||
logger.debug("Embedded controller disposed")
|
||||
} catch (error) {
|
||||
logger.error(`Error disposing embedded controller: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the WebviewProvider instance
|
||||
*
|
||||
* The WebviewProvider wraps the Controller and provides access to
|
||||
* the webview-related functionality.
|
||||
*
|
||||
* @returns The WebviewProvider instance if initialized, undefined otherwise
|
||||
*/
|
||||
export function getWebviewProvider(): WebviewProvider | undefined {
|
||||
return webviewProvider
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize only the HostProvider for lightweight CLI operations
|
||||
*
|
||||
* This is a minimal initialization that sets up just enough infrastructure
|
||||
* to read task history and messages from disk, without initializing the
|
||||
* full Controller (which starts MCP servers, etc.)
|
||||
*
|
||||
* Use this for read-only operations like `task dump` and `task list`.
|
||||
*
|
||||
* @param logger - Logger instance for CLI output
|
||||
* @param configDir - Optional custom config directory (defaults to ~/.cline)
|
||||
*/
|
||||
export function initializeHostProviderOnly(logger: Logger, configDir?: string): void {
|
||||
if (isHostProviderInitialized()) {
|
||||
return
|
||||
}
|
||||
|
||||
const { extensionContext, DATA_DIR, EXTENSION_DIR } = initializeContext(configDir)
|
||||
|
||||
logger.debug(`Using data directory: ${DATA_DIR}`)
|
||||
logger.debug(`Using extension directory: ${EXTENSION_DIR}`)
|
||||
|
||||
setupHostProvider(extensionContext, EXTENSION_DIR, DATA_DIR, logger)
|
||||
logger.debug("HostProvider initialized (lightweight mode)")
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { ExternalCommentReviewController } from "@hosts/external/ExternalCommentReviewController"
|
||||
import { ExternalWebviewProvider } from "@hosts/external/ExternalWebviewProvider"
|
||||
import { execSync } from "child_process"
|
||||
import type { ExtensionContext } from "vscode"
|
||||
import type { WebviewProvider } from "@/core/webview"
|
||||
import { AuthHandler } from "@/hosts/external/AuthHandler"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import type { DiffViewProvider } from "@/integrations/editor/DiffViewProvider"
|
||||
import { StandaloneTerminalManager } from "@/integrations/terminal"
|
||||
import { StandaloneDiffViewProvider } from "../integrations/editor/StandaloneDiffViewProvider.js"
|
||||
import type { Logger } from "../types/logger.js"
|
||||
import { NOISE_PATTERNS } from "./console-filter.js"
|
||||
import { StandaloneHostBridgeClient } from "./standalone-hostbridge-client.js"
|
||||
|
||||
/**
|
||||
* Initialize the HostProvider for CLI mode
|
||||
*
|
||||
* This sets up the host provider with CLI-appropriate implementations
|
||||
* of the various providers (webview, diff view, terminal, etc.)
|
||||
*
|
||||
* @param extensionContext - VSCode-like extension context
|
||||
* @param extensionDir - Directory where the extension is installed
|
||||
* @param dataDir - Directory for Cline data storage
|
||||
* @param logger - Logger instance for output
|
||||
*/
|
||||
export function setupHostProvider(
|
||||
extensionContext: ExtensionContext,
|
||||
extensionDir: string,
|
||||
dataDir: string,
|
||||
logger: Logger,
|
||||
): void {
|
||||
const createWebview = (): WebviewProvider => {
|
||||
return new ExternalWebviewProvider(extensionContext)
|
||||
}
|
||||
|
||||
const createDiffView = (): DiffViewProvider => {
|
||||
return new StandaloneDiffViewProvider((message) => logger.info(message))
|
||||
}
|
||||
|
||||
const createCommentReview = () => new ExternalCommentReviewController()
|
||||
|
||||
const createTerminalManager = () => new StandaloneTerminalManager()
|
||||
|
||||
const getCallbackUrl = async (): Promise<string> => {
|
||||
return AuthHandler.getInstance().getCallbackUrl()
|
||||
}
|
||||
|
||||
const getBinaryLocation = async (name: string): Promise<string> => {
|
||||
// For ripgrep, use the bundled @vscode/ripgrep package
|
||||
if (name === "rg") {
|
||||
try {
|
||||
// Dynamic import to handle the external module
|
||||
const { rgPath } = await import("@vscode/ripgrep")
|
||||
return rgPath
|
||||
} catch {
|
||||
// Fallback to system ripgrep if @vscode/ripgrep is not available
|
||||
try {
|
||||
return execSync("which rg", { encoding: "utf-8" }).trim()
|
||||
} catch {
|
||||
throw new Error("ripgrep (rg) not found. Please install it via 'brew install ripgrep' or equivalent.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For other binaries, try to find them in PATH
|
||||
try {
|
||||
return execSync(`which ${name}`, { encoding: "utf-8" }).trim()
|
||||
} catch {
|
||||
throw new Error(`Binary '${name}' not found in PATH.`)
|
||||
}
|
||||
}
|
||||
|
||||
const logToChannel = (message: string): void => {
|
||||
// Parse log level from message (format: "LEVEL message...")
|
||||
// Core Logger outputs messages as "${level} ${fullMessage}"
|
||||
const parts = message.split(" ")
|
||||
const level = parts[0]?.toUpperCase()
|
||||
const content = parts.slice(1).join(" ")
|
||||
|
||||
// Route to appropriate logger method based on parsed level
|
||||
// This respects the CLI's --verbose flag for DEBUG messages
|
||||
switch (level) {
|
||||
case "DEBUG":
|
||||
case "TRACE":
|
||||
logger.debug(content)
|
||||
break
|
||||
case "WARN":
|
||||
logger.warn(content)
|
||||
break
|
||||
case "ERROR":
|
||||
logger.error(content)
|
||||
break
|
||||
case "INFO":
|
||||
case "LOG":
|
||||
default: {
|
||||
// Filter out noisy INFO patterns (they go to debug instead)
|
||||
const messageToCheck = content || message
|
||||
const isNoise = NOISE_PATTERNS.some((pattern) => messageToCheck.includes(pattern))
|
||||
if (isNoise) {
|
||||
logger.debug(messageToCheck)
|
||||
} else {
|
||||
logger.info(messageToCheck)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HostProvider.initialize(
|
||||
createWebview,
|
||||
createDiffView,
|
||||
createCommentReview,
|
||||
createTerminalManager,
|
||||
new StandaloneHostBridgeClient(),
|
||||
logToChannel,
|
||||
getCallbackUrl,
|
||||
getBinaryLocation,
|
||||
extensionDir,
|
||||
dataDir,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if HostProvider is already initialized
|
||||
*/
|
||||
export function isHostProviderInitialized(): boolean {
|
||||
return HostProvider.isInitialized()
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the HostProvider (primarily for testing)
|
||||
*/
|
||||
export function resetHostProvider(): void {
|
||||
HostProvider.reset()
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable the AuthHandler for OAuth callback support
|
||||
* Must be called before initiating any OAuth flows
|
||||
*/
|
||||
export function enableAuthHandler(): void {
|
||||
AuthHandler.getInstance().setEnabled(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable and stop the AuthHandler
|
||||
* Should be called when auth is complete or on cleanup
|
||||
*/
|
||||
export function disableAuthHandler(): void {
|
||||
const handler = AuthHandler.getInstance()
|
||||
handler.setEnabled(false)
|
||||
handler.stop()
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { Logger } from "../types/logger.js"
|
||||
import { LogLevel } from "../types/logger.js"
|
||||
|
||||
/**
|
||||
* Console-based logger with verbose mode support
|
||||
*/
|
||||
export class ConsoleLogger implements Logger {
|
||||
private verbose: boolean
|
||||
|
||||
constructor(verbose: boolean = false) {
|
||||
this.verbose = verbose
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message at the given level should be logged
|
||||
*/
|
||||
private shouldLog(level: LogLevel): boolean {
|
||||
// TODO - implement log level filtering
|
||||
if (level === LogLevel.DEBUG) {
|
||||
return this.verbose
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a log message with timestamp and level
|
||||
*/
|
||||
private formatMessage(level: LogLevel, message: string): string {
|
||||
const timestamp = new Date().toISOString()
|
||||
const levelUpper = level.toUpperCase().padEnd(5)
|
||||
return `[${timestamp}] [${levelUpper}] ${message}`
|
||||
}
|
||||
|
||||
debug(message: string, ...args: unknown[]): void {
|
||||
if (this.shouldLog(LogLevel.DEBUG)) {
|
||||
console.debug(this.formatMessage(LogLevel.DEBUG, message), ...args)
|
||||
}
|
||||
}
|
||||
|
||||
info(message: string, ...args: unknown[]): void {
|
||||
if (this.shouldLog(LogLevel.INFO)) {
|
||||
console.info(this.formatMessage(LogLevel.INFO, message), ...args)
|
||||
}
|
||||
}
|
||||
|
||||
warn(message: string, ...args: unknown[]): void {
|
||||
if (this.shouldLog(LogLevel.WARN)) {
|
||||
console.warn(this.formatMessage(LogLevel.WARN, message), ...args)
|
||||
}
|
||||
}
|
||||
|
||||
error(message: string, ...args: unknown[]): void {
|
||||
if (this.shouldLog(LogLevel.ERROR)) {
|
||||
console.error(this.formatMessage(LogLevel.ERROR, message), ...args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create a logger instance
|
||||
*/
|
||||
export function createLogger(verbose: boolean = false): Logger {
|
||||
return new ConsoleLogger(verbose)
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Ask Message Renderer
|
||||
*
|
||||
* Handles rendering of "ask" type ClineMessages, which require user input
|
||||
* or approval. Also includes MCP server approval rendering.
|
||||
*/
|
||||
|
||||
import type { ClineAskQuestion, ClineAskUseMcpServer, ClineMessage, ClinePlanModeResponse } from "@shared/ExtensionMessage"
|
||||
import { renderMarkdown } from "./markdown-renderer.js"
|
||||
import type { ToolRenderer } from "./tool-renderer.js"
|
||||
import type { RenderContext } from "./types.js"
|
||||
|
||||
/**
|
||||
* AskMessageRenderer class
|
||||
*
|
||||
* Renders "ask" type messages to the terminal, including:
|
||||
* - Followup questions with options
|
||||
* - Command approval requests
|
||||
* - Tool approval requests
|
||||
* - API failure prompts
|
||||
* - Browser launch approval
|
||||
* - MCP server approval
|
||||
* - Plan mode responses
|
||||
*/
|
||||
export class AskMessageRenderer {
|
||||
constructor(
|
||||
private ctx: RenderContext,
|
||||
private toolRenderer: ToolRenderer,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Render an "ask" type message
|
||||
*
|
||||
* @param msg - The ClineMessage to render
|
||||
*/
|
||||
render(msg: ClineMessage): void {
|
||||
const ask = msg.ask
|
||||
|
||||
switch (ask) {
|
||||
case "followup":
|
||||
this.renderFollowupQuestion(msg)
|
||||
break
|
||||
|
||||
case "plan_mode_respond":
|
||||
this.renderPlanModeResponse(msg)
|
||||
break
|
||||
|
||||
case "command":
|
||||
this.ctx.formatter.raw(`\n💻 Execute command?`)
|
||||
this.ctx.formatter.raw(` $ ${msg.text || ""}`)
|
||||
this.ctx.formatter.info(" [y/n] or [yy to auto-approve commands]")
|
||||
break
|
||||
|
||||
case "tool":
|
||||
this.toolRenderer.renderToolApproval(msg)
|
||||
break
|
||||
|
||||
case "api_req_failed":
|
||||
this.ctx.formatter.error(`\n❌ API request failed`)
|
||||
if (msg.text) {
|
||||
this.ctx.formatter.raw(` ${msg.text}`)
|
||||
}
|
||||
this.ctx.formatter.info(" [retry/cancel]")
|
||||
break
|
||||
|
||||
case "resume_task":
|
||||
// this.ctx.formatter.info(`\n⏸ Task paused. Resume?`)
|
||||
// this.ctx.formatter.info(" [yes/no]")
|
||||
break
|
||||
|
||||
case "completion_result":
|
||||
// TODO end process if yolo mode
|
||||
this.ctx.formatter.success(`\n✅ Task Complete!`)
|
||||
if (msg.text) {
|
||||
this.ctx.formatter.raw(msg.text)
|
||||
}
|
||||
break
|
||||
|
||||
case "browser_action_launch":
|
||||
this.ctx.formatter.raw(`\n🌐 Launch browser?`)
|
||||
if (msg.text) {
|
||||
this.ctx.formatter.raw(` URL: ${msg.text}`)
|
||||
}
|
||||
this.ctx.formatter.info(" [y/n] or [yy to auto-approve browser]")
|
||||
break
|
||||
|
||||
case "use_mcp_server":
|
||||
this.renderMcpServerApproval(msg)
|
||||
break
|
||||
|
||||
case "mistake_limit_reached":
|
||||
this.ctx.formatter.warn(`\n! Mistake limit reached`)
|
||||
if (msg.text) {
|
||||
this.ctx.formatter.raw(msg.text)
|
||||
}
|
||||
this.ctx.formatter.info(" [continue/stop]")
|
||||
break
|
||||
|
||||
default:
|
||||
if (msg.text) {
|
||||
this.ctx.formatter.raw(`\n❓ ${msg.text}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a followup question with numbered options
|
||||
*
|
||||
* @param msg - The ClineMessage with question information
|
||||
*/
|
||||
private renderFollowupQuestion(msg: ClineMessage): void {
|
||||
// Clear previous options
|
||||
this.ctx.setCurrentOptions([])
|
||||
|
||||
if (!msg.text) {
|
||||
this.ctx.formatter.raw(`\n❓ Question`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const question = JSON.parse(msg.text) as ClineAskQuestion
|
||||
this.ctx.formatter.raw(`\n❓ ${question.question}`)
|
||||
|
||||
// Display options as numbered list if present
|
||||
if (question.options && question.options.length > 0) {
|
||||
this.ctx.setCurrentOptions(question.options)
|
||||
this.ctx.formatter.raw("")
|
||||
for (let i = 0; i < question.options.length; i++) {
|
||||
this.ctx.formatter.raw(` ${i + 1}. ${question.options[i]}`)
|
||||
}
|
||||
this.ctx.formatter.raw("")
|
||||
this.ctx.formatter.info(" Enter a number to select, or type your response:")
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, output as plain text
|
||||
this.ctx.formatter.raw(`\n❓ ${msg.text}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a plan mode response with markdown rendering
|
||||
*
|
||||
* @param msg - The ClineMessage with plan mode response
|
||||
*/
|
||||
private renderPlanModeResponse(msg: ClineMessage): void {
|
||||
// Clear previous options
|
||||
this.ctx.setCurrentOptions([])
|
||||
|
||||
if (!msg.text) {
|
||||
this.ctx.formatter.info(`\n📝 Plan Mode Response Required`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const planResponse = JSON.parse(msg.text) as ClinePlanModeResponse
|
||||
|
||||
this.ctx.formatter.raw("")
|
||||
// Render the markdown response
|
||||
const rendered = renderMarkdown(planResponse.response)
|
||||
this.ctx.formatter.raw(rendered)
|
||||
|
||||
// Display options as numbered list if present
|
||||
if (planResponse.options && planResponse.options.length > 0) {
|
||||
this.ctx.setCurrentOptions(planResponse.options)
|
||||
this.ctx.formatter.raw("")
|
||||
for (let i = 0; i < planResponse.options.length; i++) {
|
||||
this.ctx.formatter.raw(` ${i + 1}. ${planResponse.options[i]}`)
|
||||
}
|
||||
this.ctx.formatter.raw("")
|
||||
this.ctx.formatter.info(" Enter a number to select, or type your response:")
|
||||
} else {
|
||||
this.ctx.formatter.raw("")
|
||||
this.ctx.formatter.info(" Toggle to Act mode to execute, or provide feedback:")
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, output as plain text
|
||||
this.ctx.formatter.info(`\n📝 Plan Mode Response Required`)
|
||||
this.ctx.formatter.raw(msg.text)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render MCP server approval request
|
||||
*
|
||||
* @param msg - The ClineMessage with MCP server information
|
||||
*/
|
||||
private renderMcpServerApproval(msg: ClineMessage): void {
|
||||
if (!msg.text) {
|
||||
this.ctx.formatter.raw(`\n🔌 MCP server approval required`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const mcp = JSON.parse(msg.text) as ClineAskUseMcpServer
|
||||
this.ctx.formatter.raw(`\n🔌 MCP: ${mcp.serverName}`)
|
||||
if (mcp.type === "use_mcp_tool" && mcp.toolName) {
|
||||
this.ctx.formatter.raw(` Tool: ${mcp.toolName}`)
|
||||
if (mcp.arguments) {
|
||||
this.ctx.formatter.raw(` Args: ${mcp.arguments}`)
|
||||
}
|
||||
} else if (mcp.type === "access_mcp_resource" && mcp.uri) {
|
||||
this.ctx.formatter.raw(` Resource: ${mcp.uri}`)
|
||||
}
|
||||
this.ctx.formatter.info(" [y/n] or [yy to auto-approve MCP]")
|
||||
} catch {
|
||||
this.ctx.formatter.raw(`\n🔌 MCP approval: ${msg.text}`)
|
||||
this.ctx.formatter.info(" [y/n] or [yy to auto-approve MCP]")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Browser Action Renderer
|
||||
*
|
||||
* Handles rendering of browser-related messages including browser actions
|
||||
* and their results.
|
||||
*/
|
||||
|
||||
import type { BrowserActionResult, ClineMessage, ClineSayBrowserAction } from "@shared/ExtensionMessage"
|
||||
import type { RenderContext } from "./types.js"
|
||||
|
||||
/**
|
||||
* BrowserActionRenderer class
|
||||
*
|
||||
* Renders browser action messages to the terminal, including:
|
||||
* - Browser launch, click, type, scroll, and close actions
|
||||
* - Browser action results with URL and console output
|
||||
*/
|
||||
export class BrowserActionRenderer {
|
||||
constructor(private ctx: RenderContext) {}
|
||||
|
||||
/**
|
||||
* Render a browser action message
|
||||
*
|
||||
* @param msg - The ClineMessage with browser action information
|
||||
*/
|
||||
renderBrowserAction(msg: ClineMessage): void {
|
||||
if (!msg.text) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const action = JSON.parse(msg.text) as ClineSayBrowserAction
|
||||
switch (action.action) {
|
||||
case "launch":
|
||||
this.ctx.formatter.raw(`\n🌐 Browser: Launching...`)
|
||||
break
|
||||
case "click":
|
||||
this.ctx.formatter.raw(`\n🖱 Browser: Click at ${action.coordinate || "position"}`)
|
||||
break
|
||||
case "type":
|
||||
this.ctx.formatter.raw(`\n⌨ Browser: Type "${action.text || ""}"`)
|
||||
break
|
||||
case "scroll_down":
|
||||
this.ctx.formatter.raw(`\n📜 Browser: Scroll down`)
|
||||
break
|
||||
case "scroll_up":
|
||||
this.ctx.formatter.raw(`\n📜 Browser: Scroll up`)
|
||||
break
|
||||
case "close":
|
||||
this.ctx.formatter.raw(`\n🌐 Browser: Closing...`)
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
this.ctx.formatter.raw(`\n🌐 Browser: ${msg.text}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a browser action result message
|
||||
*
|
||||
* @param msg - The ClineMessage with browser action result
|
||||
*/
|
||||
renderBrowserActionResult(msg: ClineMessage): void {
|
||||
if (!msg.text) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = JSON.parse(msg.text) as BrowserActionResult
|
||||
if (result.currentUrl) {
|
||||
this.ctx.formatter.raw(` URL: ${result.currentUrl}`)
|
||||
}
|
||||
if (result.logs) {
|
||||
this.ctx.formatter.raw(` Console: ${result.logs}`)
|
||||
}
|
||||
// Note: Screenshots are not displayed in terminal
|
||||
if (result.screenshot) {
|
||||
this.ctx.formatter.raw(` 📷 Screenshot captured`)
|
||||
}
|
||||
} catch {
|
||||
this.ctx.formatter.raw(` ${msg.text}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Message Rendering Module
|
||||
*
|
||||
* Provides modular, testable renderers for formatting ClineMessages
|
||||
* for terminal output.
|
||||
*/
|
||||
|
||||
// Renderers
|
||||
export { AskMessageRenderer } from "./ask-message-renderer.js"
|
||||
export { BrowserActionRenderer } from "./browser-action-renderer.js"
|
||||
// Utilities
|
||||
export { renderMarkdown } from "./markdown-renderer.js"
|
||||
export { SayMessageRenderer } from "./say-message-renderer.js"
|
||||
export { ToolRenderer } from "./tool-renderer.js"
|
||||
// Types
|
||||
export type { MessageRenderer, RenderContext } from "./types.js"
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Markdown Renderer
|
||||
*
|
||||
* Handles converting markdown text to terminal-formatted output
|
||||
* using the marked library with terminal-specific styling.
|
||||
*/
|
||||
|
||||
import chalk from "chalk"
|
||||
import { type MarkedExtension, marked } from "marked"
|
||||
import { markedTerminal } from "marked-terminal"
|
||||
|
||||
// Configure marked with terminal renderer
|
||||
// Note: @types/marked-terminal is outdated and returns wrong type, cast to MarkedExtension
|
||||
marked.use(
|
||||
markedTerminal({
|
||||
heading: chalk.cyan.bold,
|
||||
firstHeading: chalk.magenta.bold.underline,
|
||||
strong: chalk.yellow.bold,
|
||||
em: chalk.blue.italic,
|
||||
codespan: chalk.greenBright,
|
||||
}) as unknown as MarkedExtension,
|
||||
)
|
||||
|
||||
/**
|
||||
* Render markdown text to terminal-formatted output
|
||||
*
|
||||
* @param text - Markdown text to render
|
||||
* @returns Terminal-formatted string
|
||||
*/
|
||||
export function renderMarkdown(text: string): string {
|
||||
try {
|
||||
const rendered = marked.parse(text)
|
||||
// marked.parse returns string | Promise<string>, we only use sync mode
|
||||
return (typeof rendered === "string" ? rendered : text).trim()
|
||||
} catch {
|
||||
return text
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Say Message Renderer
|
||||
*
|
||||
* Handles rendering of "say" type ClineMessages, which are informational
|
||||
* messages that don't require user input.
|
||||
*/
|
||||
|
||||
import type { ClineApiReqInfo, ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { getApiMetrics } from "@shared/getApiMetrics"
|
||||
import type { BrowserActionRenderer } from "./browser-action-renderer.js"
|
||||
import { renderMarkdown } from "./markdown-renderer.js"
|
||||
import type { ToolRenderer } from "./tool-renderer.js"
|
||||
import type { RenderContext } from "./types.js"
|
||||
|
||||
/**
|
||||
* SayMessageRenderer class
|
||||
*
|
||||
* Renders "say" type messages to the terminal, including:
|
||||
* - Task information
|
||||
* - AI text and reasoning
|
||||
* - Errors and retries
|
||||
* - API request status
|
||||
* - Command output
|
||||
* - Tool and browser actions
|
||||
* - Checkpoints
|
||||
*/
|
||||
export class SayMessageRenderer {
|
||||
constructor(
|
||||
private ctx: RenderContext,
|
||||
private toolRenderer: ToolRenderer,
|
||||
private browserRenderer: BrowserActionRenderer,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Render a "say" type message
|
||||
*
|
||||
* @param msg - The ClineMessage to render
|
||||
*/
|
||||
render(msg: ClineMessage): void {
|
||||
const say = msg.say
|
||||
|
||||
switch (say) {
|
||||
case "task":
|
||||
this.ctx.formatter.info(`\n📋 Task: ${msg.text || ""}`)
|
||||
break
|
||||
|
||||
case "text":
|
||||
case "reasoning":
|
||||
if (msg.text) {
|
||||
// Check if this is reasoning content
|
||||
if (say === "reasoning" || msg.reasoning) {
|
||||
this.ctx.formatter.raw(`💭 ${msg.reasoning || msg.text}`)
|
||||
} else {
|
||||
this.ctx.formatter.raw(msg.text)
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case "error":
|
||||
this.ctx.formatter.error(`❌ ${msg.text || "An error occurred"}`)
|
||||
break
|
||||
|
||||
case "error_retry":
|
||||
this.ctx.formatter.warn(`🔄 Retrying: ${msg.text || ""}`)
|
||||
break
|
||||
|
||||
case "api_req_started":
|
||||
this.renderApiReqStarted()
|
||||
break
|
||||
|
||||
case "api_req_finished":
|
||||
this.renderApiReqFinished(msg)
|
||||
break
|
||||
|
||||
case "completion_result":
|
||||
this.ctx.formatter.success(`\n✨ "Task completed"`)
|
||||
this.ctx.formatter.raw(renderMarkdown(msg.text || ""))
|
||||
break
|
||||
|
||||
case "user_feedback":
|
||||
this.ctx.formatter.info(`📝 User: ${msg.text || ""}`)
|
||||
break
|
||||
|
||||
case "command":
|
||||
this.ctx.formatter.code(`\n$ ${msg.text || ""}`)
|
||||
break
|
||||
|
||||
case "command_output":
|
||||
if (msg.text) {
|
||||
// Indent command output
|
||||
const lines = msg.text.split("\n")
|
||||
for (const line of lines) {
|
||||
this.ctx.formatter.raw(` ${line}`)
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case "tool":
|
||||
this.toolRenderer.renderToolMessage(msg)
|
||||
break
|
||||
|
||||
case "browser_action":
|
||||
this.browserRenderer.renderBrowserAction(msg)
|
||||
break
|
||||
|
||||
case "browser_action_result":
|
||||
this.browserRenderer.renderBrowserActionResult(msg)
|
||||
break
|
||||
|
||||
case "mcp_server_request_started":
|
||||
this.ctx.formatter.info(`🔌 MCP request: ${msg.text || ""}`)
|
||||
break
|
||||
|
||||
case "mcp_server_response":
|
||||
this.ctx.formatter.raw(` Response: ${msg.text || ""}`)
|
||||
break
|
||||
|
||||
case "checkpoint_created":
|
||||
// Display checkpoint ID (timestamp) so users can reference it for /restore
|
||||
const hashInfo = msg.lastCheckpointHash ? ` (${msg.lastCheckpointHash.slice(0, 8)})` : ""
|
||||
this.ctx.formatter.info(`💾 Checkpoint created [ID: ${msg.ts}]${hashInfo}`)
|
||||
break
|
||||
|
||||
case "shell_integration_warning":
|
||||
this.ctx.formatter.warn(`! Shell integration: ${msg.text || ""}`)
|
||||
break
|
||||
|
||||
case "diff_error":
|
||||
this.ctx.formatter.error(`❌ Diff error: ${msg.text || ""}`)
|
||||
break
|
||||
|
||||
case "task_progress":
|
||||
this.ctx.formatter.info("Making Progress...")
|
||||
break
|
||||
|
||||
default:
|
||||
// Handle any other say types
|
||||
if (msg.text) {
|
||||
this.ctx.formatter.raw(msg.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render API request started message with cumulative session metrics
|
||||
*/
|
||||
private renderApiReqStarted(): void {
|
||||
// Show cumulative session token usage
|
||||
const messages = this.ctx.getMessages()
|
||||
const metrics = getApiMetrics(messages)
|
||||
const parts: string[] = []
|
||||
|
||||
// Token counts
|
||||
parts.push(`${metrics.totalTokensIn.toLocaleString()} in / ${metrics.totalTokensOut.toLocaleString()} out`)
|
||||
|
||||
// Cache info if available
|
||||
if (metrics.totalCacheReads !== undefined || metrics.totalCacheWrites !== undefined) {
|
||||
const cacheReads = metrics.totalCacheReads ?? 0
|
||||
const cacheWrites = metrics.totalCacheWrites ?? 0
|
||||
parts.push(`cache: ${cacheReads.toLocaleString()}r/${cacheWrites.toLocaleString()}w`)
|
||||
}
|
||||
|
||||
// Cost
|
||||
if (metrics.totalCost > 0) {
|
||||
parts.push(`$${metrics.totalCost.toFixed(4)}`)
|
||||
}
|
||||
|
||||
this.ctx.formatter.info(`🔄 API request started... [Session: ${parts.join(" | ")}]`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render API request finished message with token counts and cost
|
||||
*/
|
||||
private renderApiReqFinished(msg: ClineMessage): void {
|
||||
if (msg.text) {
|
||||
try {
|
||||
const info = JSON.parse(msg.text) as ClineApiReqInfo
|
||||
const tokens = `${info.tokensIn || 0} in / ${info.tokensOut || 0} out`
|
||||
const cost = info.cost ? ` ($${info.cost.toFixed(4)})` : ""
|
||||
this.ctx.formatter.success(`✅ API request complete: ${tokens}${cost}`)
|
||||
} catch {
|
||||
this.ctx.formatter.success("✅ API request complete")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Tool Renderer
|
||||
*
|
||||
* Handles rendering of tool-related messages including tool operations,
|
||||
* tool approval requests, and diff output formatting.
|
||||
*/
|
||||
|
||||
import type { ClineMessage, ClineSayTool } from "@shared/ExtensionMessage"
|
||||
import type { RenderContext } from "./types.js"
|
||||
|
||||
/**
|
||||
* ToolRenderer class
|
||||
*
|
||||
* Renders tool-related messages to the terminal, including:
|
||||
* - Tool operation results (file edits, reads, searches, etc.)
|
||||
* - Tool approval requests with diffs
|
||||
* - Diff formatting with color-coded additions/deletions
|
||||
*/
|
||||
export class ToolRenderer {
|
||||
constructor(private ctx: RenderContext) {}
|
||||
|
||||
/**
|
||||
* Render a tool operation message (say type)
|
||||
*
|
||||
* @param msg - The ClineMessage with tool information
|
||||
*/
|
||||
renderToolMessage(msg: ClineMessage): void {
|
||||
if (!msg.text) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const tool = JSON.parse(msg.text) as ClineSayTool
|
||||
switch (tool.tool) {
|
||||
case "editedExistingFile":
|
||||
this.ctx.formatter.raw(`\n📝 Edited: ${tool.path || "file"}`)
|
||||
if (tool.diff) {
|
||||
this.renderDiff(tool.diff)
|
||||
}
|
||||
break
|
||||
|
||||
case "newFileCreated":
|
||||
this.ctx.formatter.raw(`\n📄 Created: ${tool.path || "file"}`)
|
||||
break
|
||||
|
||||
case "fileDeleted":
|
||||
this.ctx.formatter.raw(`\n🗑 Deleted: ${tool.path || "file"}`)
|
||||
break
|
||||
|
||||
case "readFile":
|
||||
this.ctx.formatter.raw(`\n📖 Read: ${tool.path || "file"}`)
|
||||
break
|
||||
|
||||
case "listFilesTopLevel":
|
||||
case "listFilesRecursive":
|
||||
this.ctx.formatter.raw(`\n📂 Listed: ${tool.path || "directory"}`)
|
||||
break
|
||||
|
||||
case "searchFiles":
|
||||
this.ctx.formatter.raw(`\n🔍 Searched: ${tool.regex || "pattern"} in ${tool.path || "directory"}`)
|
||||
break
|
||||
|
||||
case "webFetch":
|
||||
case "webSearch":
|
||||
this.ctx.formatter.raw(`\n🌐 Web: ${tool.content || ""}`)
|
||||
break
|
||||
|
||||
default:
|
||||
this.ctx.formatter.raw(`\n🔧 Tool: ${tool.tool}`)
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, just output raw
|
||||
this.ctx.formatter.raw(`\n🔧 ${msg.text}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a tool approval request (ask type)
|
||||
*
|
||||
* @param msg - The ClineMessage with tool approval information
|
||||
*/
|
||||
renderToolApproval(msg: ClineMessage): void {
|
||||
if (!msg.text) {
|
||||
this.ctx.formatter.raw(`\n🔧 Tool approval required`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const tool = JSON.parse(msg.text) as ClineSayTool
|
||||
this.ctx.formatter.raw(`\n🔧 Approve ${tool.tool}?`)
|
||||
if (tool.path) {
|
||||
this.ctx.formatter.raw(` Path: ${tool.path}`)
|
||||
}
|
||||
// Check both diff and content fields - the extension stores diffs in content field
|
||||
const diffContent = tool.diff || tool.content
|
||||
if (diffContent && (tool.tool === "editedExistingFile" || tool.tool === "newFileCreated")) {
|
||||
this.renderDiff(diffContent)
|
||||
}
|
||||
// Show appropriate auto-approve hint based on tool type
|
||||
const autoApproveHint = this.getAutoApproveHint(tool.tool)
|
||||
this.ctx.formatter.info(` [y/n]${autoApproveHint}`)
|
||||
} catch {
|
||||
this.ctx.formatter.raw(`\n🔧 Tool approval: ${msg.text}`)
|
||||
this.ctx.formatter.info(" [y/n] or [yy to auto-approve]")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render diff content with color-coded additions and deletions
|
||||
*
|
||||
* @param diff - The diff string to render
|
||||
*/
|
||||
renderDiff(diff: string): void {
|
||||
const lines = diff.split("\n")
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("+")) {
|
||||
this.ctx.formatter.raw(` \x1b[32m${line}\x1b[0m`) // Green for additions
|
||||
} else if (line.startsWith("-")) {
|
||||
this.ctx.formatter.raw(` \x1b[31m${line}\x1b[0m`) // Red for deletions
|
||||
} else {
|
||||
this.ctx.formatter.raw(` ${line}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the auto-approve hint text based on tool type
|
||||
*
|
||||
* @param toolType - The type of tool being approved
|
||||
* @returns Hint text for auto-approval
|
||||
*/
|
||||
getAutoApproveHint(toolType: string): string {
|
||||
switch (toolType) {
|
||||
case "editedExistingFile":
|
||||
case "newFileCreated":
|
||||
case "fileDeleted":
|
||||
return " or [yy to auto-approve edits]"
|
||||
case "readFile":
|
||||
case "listFilesTopLevel":
|
||||
case "listFilesRecursive":
|
||||
case "listCodeDefinitionNames":
|
||||
case "searchFiles":
|
||||
case "webFetch":
|
||||
case "webSearch":
|
||||
return " or [yy to auto-approve reads]"
|
||||
default:
|
||||
return " or [yy to auto-approve]"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Types and interfaces for message rendering
|
||||
*
|
||||
* These types define the contract between the CliWebviewAdapter and
|
||||
* the various message renderers, enabling modular and testable rendering.
|
||||
*/
|
||||
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import type { OutputFormatter } from "../output/types.js"
|
||||
|
||||
/**
|
||||
* Context provided to renderers for outputting messages
|
||||
*
|
||||
* This allows renderers to access shared resources without
|
||||
* creating circular dependencies with the adapter.
|
||||
*/
|
||||
export interface RenderContext {
|
||||
/** Formatter for outputting to the terminal */
|
||||
formatter: OutputFormatter
|
||||
|
||||
/** Get current messages from the controller */
|
||||
getMessages: () => ClineMessage[]
|
||||
|
||||
/** Set the current options for numbered selection (used by followup questions) */
|
||||
setCurrentOptions: (options: string[]) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for message renderers
|
||||
*
|
||||
* Each renderer is responsible for a specific category of messages
|
||||
* (e.g., say messages, ask messages, tool messages).
|
||||
*/
|
||||
export interface MessageRenderer {
|
||||
/**
|
||||
* Render a message to the terminal
|
||||
*
|
||||
* @param msg - The ClineMessage to render
|
||||
*/
|
||||
render(msg: ClineMessage): void
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Output formatting system - factory and exports
|
||||
*/
|
||||
|
||||
import { createJsonFormatter } from "./json-formatter.js"
|
||||
import { createPlainFormatter } from "./plain-formatter.js"
|
||||
import { createRichFormatter } from "./rich-formatter.js"
|
||||
import type { OutputFormat, OutputFormatter } from "./types.js"
|
||||
|
||||
// Re-export formatter classes for direct use if needed
|
||||
export { JsonFormatter } from "./json-formatter.js"
|
||||
export { PlainFormatter } from "./plain-formatter.js"
|
||||
export { RichFormatter } from "./rich-formatter.js"
|
||||
// Re-export types
|
||||
export type { ClineMessage, OutputFormat, OutputFormatter, TaskInfo } from "./types.js"
|
||||
|
||||
/**
|
||||
* Default output format based on TTY detection
|
||||
*/
|
||||
export function getDefaultFormat(): OutputFormat {
|
||||
// Use rich format if stdout is a TTY, otherwise plain
|
||||
return process.stdout.isTTY ? "rich" : "plain"
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a string is a valid output format
|
||||
*/
|
||||
export function isValidFormat(format: string): format is OutputFormat {
|
||||
return format === "rich" || format === "json" || format === "plain"
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse output format from string, with validation
|
||||
*/
|
||||
export function parseOutputFormat(format: string | undefined): OutputFormat {
|
||||
if (!format) {
|
||||
return getDefaultFormat()
|
||||
}
|
||||
|
||||
if (!isValidFormat(format)) {
|
||||
throw new Error(`Invalid output format: ${format}. Valid options are: rich, json, plain`)
|
||||
}
|
||||
|
||||
return format
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an output formatter based on the specified format
|
||||
*/
|
||||
export function createFormatter(format: OutputFormat): OutputFormatter {
|
||||
switch (format) {
|
||||
case "json":
|
||||
return createJsonFormatter()
|
||||
case "plain":
|
||||
return createPlainFormatter()
|
||||
case "rich":
|
||||
return createRichFormatter()
|
||||
default:
|
||||
// TypeScript exhaustiveness check
|
||||
const _exhaustive: never = format
|
||||
throw new Error(`Unknown output format: ${_exhaustive}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an output formatter from an optional format string.
|
||||
* Uses default format if undefined or invalid.
|
||||
*/
|
||||
export function createFormatterFromOption(format: string | undefined): OutputFormatter {
|
||||
return createFormatter(parseOutputFormat(format))
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* JSON output formatter - structured output for scripting/automation
|
||||
* Each output is a single JSON line for easy parsing
|
||||
*/
|
||||
|
||||
import type { ClineMessage, OutputFormatter, TaskInfo } from "./types.js"
|
||||
|
||||
/**
|
||||
* JSON output wrapper type
|
||||
*/
|
||||
interface JsonOutput {
|
||||
type: "message" | "error" | "success" | "warn" | "info" | "table" | "list" | "tasks" | "keyValue" | "raw" | "code"
|
||||
data: unknown
|
||||
ts: number
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON formatter implementation
|
||||
*/
|
||||
export class JsonFormatter implements OutputFormatter {
|
||||
/**
|
||||
* Output a JSON line to stdout
|
||||
*/
|
||||
private output(type: JsonOutput["type"], data: unknown): void {
|
||||
const output: JsonOutput = {
|
||||
type,
|
||||
data,
|
||||
ts: Date.now(),
|
||||
}
|
||||
console.log(JSON.stringify(output))
|
||||
}
|
||||
|
||||
message(msg: ClineMessage): void {
|
||||
this.output("message", msg)
|
||||
}
|
||||
|
||||
error(err: Error | string): void {
|
||||
const data = err instanceof Error ? { message: err.message, name: err.name, stack: err.stack } : { message: err }
|
||||
this.output("error", data)
|
||||
}
|
||||
|
||||
success(text: string): void {
|
||||
this.output("success", { message: text })
|
||||
}
|
||||
|
||||
warn(text: string): void {
|
||||
this.output("warn", { message: text })
|
||||
}
|
||||
|
||||
info(text: string): void {
|
||||
this.output("info", { message: text })
|
||||
}
|
||||
|
||||
table(data: Record<string, unknown>[], columns?: string[]): void {
|
||||
this.output("table", { rows: data, columns: columns || (data.length > 0 ? Object.keys(data[0]) : []) })
|
||||
}
|
||||
|
||||
list(items: string[]): void {
|
||||
this.output("list", { items })
|
||||
}
|
||||
|
||||
tasks(tasks: TaskInfo[]): void {
|
||||
this.output("tasks", { tasks })
|
||||
}
|
||||
|
||||
keyValue(data: Record<string, unknown>): void {
|
||||
this.output("keyValue", data)
|
||||
}
|
||||
|
||||
raw(text: string): void {
|
||||
this.output("raw", { content: text })
|
||||
}
|
||||
|
||||
code(code: any): void {
|
||||
this.output("code", { code })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a JSON formatter instance
|
||||
*/
|
||||
export function createJsonFormatter(): OutputFormatter {
|
||||
return new JsonFormatter()
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user