mirror of
https://github.com/cline/cline.git
synced 2026-09-01 15:11:04 +08:00
Compare commits
521 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e9bef4253a | |||
| 2a1c3fd9ba | |||
| 5ac991902b | |||
| c40c7c098c | |||
| b62c7874f6 | |||
| 0e26ba46d0 | |||
| 3b313ae41f | |||
| 7e74f35f23 | |||
| 59c05f4b84 | |||
| 5d6424682f | |||
| b1a8db252a | |||
| 5ae47fb90b | |||
| 28c2697ae1 | |||
| cf01317885 | |||
| 7d5eebe192 | |||
| 91a3636356 | |||
| 5d02eea9cd | |||
| 45b2786dbf | |||
| 4924192b64 | |||
| 28c548b3ee | |||
| 9b9035ea4a | |||
| e4b39aeb22 | |||
| f7c54e964f | |||
| d116ac5dcf | |||
| ac22d5d81a | |||
| b57aefb5a1 | |||
| 11da3ee89e | |||
| e4e63912dd | |||
| f17d523b2e | |||
| edbba8b7f6 | |||
| 7b09999fdf | |||
| 01240744a2 | |||
| 2944416758 | |||
| b5b503dd50 | |||
| a24ab0c6b8 | |||
| 3bc6cc6a92 | |||
| bd7f2a29d6 | |||
| b1c5f0b811 | |||
| ac30e49e0b | |||
| d86f5ed4c9 | |||
| 336d31f95f | |||
| fc9c413058 | |||
| e0282826fc | |||
| bf83b816e2 | |||
| 24033613cd | |||
| f76cfbce48 | |||
| 2a63545224 | |||
| 5308dedc81 | |||
| 4c699da00b | |||
| 6cff60b53b | |||
| 24dcd9ea7c | |||
| 3cded47baf | |||
| 7000bb1894 | |||
| 0de65457c1 | |||
| c83b404764 | |||
| 96b48182c6 | |||
| 3b6e42f0ce | |||
| adb3759738 | |||
| 215fc36d17 | |||
| 53bd0ecd8d | |||
| 9e851a8f7c | |||
| 5d94bbc6fd | |||
| ea66c6c584 | |||
| be7f349693 | |||
| 9c03dfa717 | |||
| c5601d8d7d | |||
| a281aea0e7 | |||
| e8bc6b9794 | |||
| e018199fef | |||
| 35ecf3e551 | |||
| ee028033c7 | |||
| 7adfcabfa0 | |||
| 51201b00be | |||
| 0d43d014eb | |||
| df7d5062bc | |||
| 98aec6c9d3 | |||
| 5a65b12a43 | |||
| 741bea5af8 | |||
| ad1e35a425 | |||
| 2e2239b138 | |||
| 03aac0cd80 | |||
| 22cc73f614 | |||
| 06b05ddfe9 | |||
| 2670a4a171 | |||
| 1699c9a63a | |||
| 808dd42ae9 | |||
| 71af56f493 | |||
| 1167b4f3a6 | |||
| e8d6370b0c | |||
| e243376a39 | |||
| 4f1be9d512 | |||
| 94d36ce719 | |||
| 5df7498f03 | |||
| c2b87252ac | |||
| be353bb3da | |||
| 0a7791de1f | |||
| 7cca102e14 | |||
| 4f591de6a9 | |||
| 60436b3ddf | |||
| df5954052d | |||
| a66a2784c3 | |||
| 47031cea25 | |||
| e29740479e | |||
| 74f607ff8e | |||
| 0edf6d777b | |||
| de630c64d4 | |||
| 3ff63562c8 | |||
| 4d6f908fbd | |||
| 6521fdcc94 | |||
| 1393eace27 | |||
| eebb99c1e3 | |||
| 9ed44f7a83 | |||
| 6a95cc5f19 | |||
| 1d0637f39c | |||
| f2c16bae7a | |||
| 204f15ce1c | |||
| 8118e11596 | |||
| f7b593df35 | |||
| 2e0358a7a1 | |||
| 0fbc10f807 | |||
| c093ca1760 | |||
| dc85299a73 | |||
| 4533ed3ea8 | |||
| 66d7664d1b | |||
| 50aeb7098f | |||
| c2e6eafb45 | |||
| 0220628c39 | |||
| 5052220195 | |||
| abf3081e56 | |||
| 125cb78a31 | |||
| 17539228ea | |||
| bdb7cc36fa | |||
| d3e1065707 | |||
| 6303951f20 | |||
| fc184e07a2 | |||
| e88f4ea412 | |||
| 133ade3f1a | |||
| 857411c035 | |||
| c59f068584 | |||
| f7561c31fa | |||
| f0a97dafc8 | |||
| d422f689a6 | |||
| 00efcc1c2b | |||
| 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 | |||
| 3c97c8dc19 | |||
| 187e40d2da | |||
| 8c38b1ccf8 | |||
| 4947a745c2 | |||
| 9e8c6df6b2 | |||
| 6586195b2d | |||
| 1aa944eff7 | |||
| 8e7b1c6884 | |||
| ee154826b6 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"cline": patch
|
||||
---
|
||||
|
||||
fix(cli): prevent hang when spawned without TTY
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix decimal input crash in OpenAI Compatible price fields (#8129)
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"cline": patch
|
||||
---
|
||||
|
||||
Supports rendering markdown table in chat view.
|
||||
@@ -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: build complete handlers when upadting the api config
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Updating script documentation and removing unnecessary continue on error
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Prevent simultaneuos refreshes when restoring auth info
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fixed missing provider from list
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
feat(skills): Make skills always enabled and remove feature toggle setting
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fixed Favorite Icon / Star from getting clipped in the task history view
|
||||
+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..."
|
||||
bun run install:all
|
||||
|
||||
# Generate gRPC/protobuf types (required for TypeScript)
|
||||
echo "Generating proto types..."
|
||||
bun 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,33 @@
|
||||
# CLI Development
|
||||
|
||||
The CLI lives in `cli/` and uses React Ink for terminal UI.
|
||||
|
||||
- If needed, look at `cli/src/constants/colors.ts` for re-used terminal colors, e.g. `COLORS.primaryBlue` highlight color (selections, spinners, success states).
|
||||
- Never use `dimColor` with gray (e.g. `<Text color="gray" dimColor>`) - it's too hard to read. Use `color="gray"` for secondary text and normal foreground (no color) for primary text.
|
||||
- When thinking about how to handle state or messages from core, look at webview for how it communicates with the vs code extension.
|
||||
- When updating the webview, consider and suggest to the user to update the CLI TUI since we want to provide a similar experience to our terminal users as we do our vs code extension users.
|
||||
|
||||
## Adding New API Providers
|
||||
|
||||
When adding a new API provider to the extension, you must also update the CLI:
|
||||
|
||||
1. **Update `cli/src/components/ModelPicker.tsx`**: Add the provider to the `providerModels` map so `getDefaultModelId()` returns the correct default model. Import the models and default ID from `@shared/api`:
|
||||
```typescript
|
||||
import { newProviderDefaultModelId, newProviderModels } from "@/shared/api"
|
||||
|
||||
export const providerModels = {
|
||||
// ...existing providers
|
||||
"new-provider": { models: newProviderModels, defaultId: newProviderDefaultModelId },
|
||||
}
|
||||
```
|
||||
|
||||
2. **Use `applyProviderConfig()` for auth flows**: When implementing OAuth or other auth flows for the provider, use the shared utility at `cli/src/utils/provider-config.ts`:
|
||||
```typescript
|
||||
import { applyProviderConfig } from "../utils/provider-config"
|
||||
|
||||
// After successful auth:
|
||||
await applyProviderConfig({ providerId: "new-provider", controller })
|
||||
```
|
||||
This handles setting provider, default model, API key mapping, state persistence, and rebuilding the API handler.
|
||||
|
||||
3. **Provider-specific auth**: If the provider uses OAuth (like `openai-codex`), add handling in `SettingsPanelContent.tsx`'s `handleProviderSelect` callback. See the existing Codex OAuth flow as a reference.
|
||||
@@ -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.
|
||||
@@ -42,7 +42,7 @@ Here, we use the common `StringRequest` and `KeyValuePair` types.
|
||||
|
||||
After editing a `.proto` file, regenerate the TypeScript code. From the project root, run:
|
||||
```bash
|
||||
npm run protos
|
||||
bun run protos
|
||||
```
|
||||
This command compiles all `.proto` files and outputs the generated code to `src/generated/` and `src/shared/`. Do not edit these generated files manually.
|
||||
|
||||
|
||||
@@ -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 `bun 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
-10
@@ -16,13 +16,6 @@
|
||||
TELEMETRY_SERVICE_API_KEY=your-posthog-telemetry-api-key
|
||||
ERROR_SERVICE_API_KEY=your-posthog-error-tracking-api-key
|
||||
|
||||
# ============================================================================
|
||||
# TELEMETRY PROVIDER CONTROL
|
||||
# ============================================================================
|
||||
# Control which telemetry providers are active
|
||||
POSTHOG_TELEMETRY_ENABLED=true # Enable PostHog telemetry (default: true)
|
||||
# Set to false to disable Telemetry completely
|
||||
|
||||
# ============================================================================
|
||||
# OPENTELEMETRY (Optional - for advanced telemetry)
|
||||
# ============================================================================
|
||||
@@ -72,12 +65,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 +78,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
|
||||
|
||||
@@ -59,8 +59,8 @@ We're not looking for exhaustive documentation - just evidence that you've thoug
|
||||
<!-- Put an 'x' in all boxes that apply -->
|
||||
|
||||
- [ ] Changes are limited to a single feature, bugfix or chore (split larger changes into separate PRs)
|
||||
- [ ] Tests are passing (`npm test`) and code is formatted and linted (`npm run format && npm run lint`)
|
||||
- [ ] I have created a changeset using `npm run changeset` (required for user-facing changes)
|
||||
- [ ] Tests are passing (`bun test`) and code is formatted and linted (`bun run format && bun run lint`)
|
||||
- [ ] I have created a changeset using `bun run changeset` (required for user-facing changes)
|
||||
- [ ] I have reviewed [contributor guidelines](https://github.com/cline/cline/blob/main/CONTRIBUTING.md)
|
||||
|
||||
### Screenshots
|
||||
|
||||
@@ -62,9 +62,9 @@ class TestCoverage(unittest.TestCase):
|
||||
|
||||
# Use xvfb-run on Linux
|
||||
if sys.platform.startswith('linux'):
|
||||
cmd = f"cd {root_dir} && xvfb-run -a npm run test:coverage > {cls.extension_coverage_file} 2>&1"
|
||||
cmd = f"cd {root_dir} && xvfb-run -a bun run test:coverage > {cls.extension_coverage_file} 2>&1"
|
||||
else:
|
||||
cmd = f"cd {root_dir} && npm run test:coverage > {cls.extension_coverage_file} 2>&1"
|
||||
cmd = f"cd {root_dir} && bun run test:coverage > {cls.extension_coverage_file} 2>&1"
|
||||
|
||||
log("Running extension tests...")
|
||||
log(f"Command: {cmd}")
|
||||
@@ -73,7 +73,7 @@ class TestCoverage(unittest.TestCase):
|
||||
|
||||
# Run webview tests with coverage
|
||||
log("Running webview tests...")
|
||||
cmd = f"cd {webview_dir} && npm run test:coverage > {cls.webview_coverage_file} 2>&1"
|
||||
cmd = f"cd {webview_dir} && bun run test:coverage > {cls.webview_coverage_file} 2>&1"
|
||||
log(f"Command: {cmd}")
|
||||
result = subprocess.run(cmd, shell=True, check=False, capture_output=True, text=True)
|
||||
log(f"Webview tests exit code: {result.returncode}")
|
||||
|
||||
@@ -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,284 @@
|
||||
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: Print HEAD commit
|
||||
run: |
|
||||
echo "HEAD is at: $(git rev-parse HEAD)"
|
||||
echo "Short: $(git rev-parse --short HEAD)"
|
||||
git log -1 --format="Commit: %H%nAuthor: %an <%ae>%nDate: %ad%nMessage: %s"
|
||||
|
||||
- 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 by noting the commit hash you reviewed:
|
||||
```bash
|
||||
git rev-parse --short HEAD
|
||||
```
|
||||
Include this at the top of your comment: "Reviewed at commit: <short hash>"
|
||||
|
||||
Then thank them 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,325 @@
|
||||
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: Print HEAD commit
|
||||
run: |
|
||||
echo "HEAD is at: $(git rev-parse HEAD)"
|
||||
echo "Short: $(git rev-parse --short HEAD)"
|
||||
git log -1 --format="Commit: %H%nAuthor: %an <%ae>%nDate: %ad%nMessage: %s"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
|
||||
- name: Install and Verify Cline CLI
|
||||
run: |
|
||||
npm install -g cline
|
||||
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 by noting the commit hash you reviewed:
|
||||
```bash
|
||||
git rev-parse --short HEAD
|
||||
```
|
||||
Include this at the top of your comment: "Reviewed at commit: <short hash>"
|
||||
|
||||
Then thank them 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'
|
||||
+12
-28
@@ -35,26 +35,10 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
# Cache root dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache root dependencies
|
||||
uses: actions/cache@v4
|
||||
id: root-cache
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
# Cache webview-ui dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache webview-ui dependencies
|
||||
uses: actions/cache@v4
|
||||
id: webview-cache
|
||||
with:
|
||||
path: webview-ui/node_modules
|
||||
key: ${{ runner.os }}-npm-webview-${{ hashFiles('webview-ui/package-lock.json') }}
|
||||
bun-version: latest
|
||||
|
||||
# Cache VS Code installation
|
||||
- name: Cache VS Code
|
||||
@@ -75,17 +59,17 @@ jobs:
|
||||
~/.cache/ms-playwright
|
||||
~/Library/Caches/ms-playwright
|
||||
~/AppData/Local/ms-playwright
|
||||
key: playwright-browsers-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
key: playwright-browsers-${{ runner.os }}-${{ hashFiles('bun.lockb') }}
|
||||
restore-keys: |
|
||||
playwright-browsers-${{ runner.os }}-
|
||||
|
||||
- name: Install root dependencies
|
||||
if: steps.root-cache.outputs.cache-hit != 'true'
|
||||
run: npm ci
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
if: steps.webview-cache.outputs.cache-hit != 'true'
|
||||
run: cd webview-ui && npm ci
|
||||
- name: Install vsce
|
||||
run: bun add -g @vscode/vsce
|
||||
|
||||
- name: Install xvfb on Linux
|
||||
if: matrix.runner == 'ubuntu'
|
||||
@@ -94,11 +78,11 @@ jobs:
|
||||
# Run optimized E2E tests (eliminates redundant builds)
|
||||
- name: Run E2E tests - Linux
|
||||
if: matrix.runner == 'ubuntu'
|
||||
run: xvfb-run -a npm run test:e2e:optimal
|
||||
run: xvfb-run -a bun run test:e2e:optimal
|
||||
|
||||
- name: Run E2E tests - Non-Linux
|
||||
if: matrix.runner != 'ubuntu'
|
||||
run: npm run test:e2e:optimal
|
||||
run: bun run test:e2e:optimal
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ failure() }}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
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"
|
||||
|
||||
# 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') }}
|
||||
|
||||
- name: Install root dependencies and CLI dependencies
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: npm ci --include=optional # this will also install cli deps because "cli" in included in root package.json workspaces field
|
||||
|
||||
- name: Generate Protos
|
||||
run: npm run protos
|
||||
|
||||
- name: Read release version
|
||||
id: version
|
||||
run: |
|
||||
# Read version from cli/package.json
|
||||
VERSION=$(node -p "require('./cli/package.json').version")
|
||||
echo "Release version: $VERSION"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- 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 }}
|
||||
run: node scripts/package-npm.mjs
|
||||
|
||||
- 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,134 @@
|
||||
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"
|
||||
|
||||
# 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') }}
|
||||
|
||||
- name: Install root dependencies and CLI dependencies
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: npm ci --include=optional # this will also install cli deps because "cli" in included in root package.json workspaces field
|
||||
|
||||
- name: Generate Protos
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: npm run protos
|
||||
|
||||
- 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., "2.0.0")
|
||||
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: Build and package CLI
|
||||
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 }}
|
||||
run: node scripts/package-npm.mjs
|
||||
|
||||
- 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 }}"
|
||||
@@ -33,37 +33,16 @@ jobs:
|
||||
fi
|
||||
echo "Found recent commits, proceeding with build"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
node-version: "lts/*"
|
||||
bun-version: latest
|
||||
|
||||
# Cache root dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache root dependencies
|
||||
uses: actions/cache@v4
|
||||
id: root-cache
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
# Cache webview-ui dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache webview-ui dependencies
|
||||
uses: actions/cache@v4
|
||||
id: webview-cache
|
||||
with:
|
||||
path: webview-ui/node_modules
|
||||
key: ${{ runner.os }}-npm-webview-${{ hashFiles('webview-ui/package-lock.json') }}
|
||||
|
||||
- name: Install root dependencies
|
||||
if: steps.root-cache.outputs.cache-hit != 'true'
|
||||
run: npm ci --include=optional
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
if: steps.webview-cache.outputs.cache-hit != 'true'
|
||||
run: cd webview-ui && npm ci --include=optional
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Install Publishing Tools
|
||||
run: npm install -g @vscode/vsce ovsx
|
||||
run: bun add -g @vscode/vsce ovsx
|
||||
|
||||
- name: Publish Extension as Pre-release
|
||||
env:
|
||||
@@ -79,4 +58,4 @@ jobs:
|
||||
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 }}
|
||||
run: npm run publish:marketplace:nightly
|
||||
run: bun run publish:marketplace:nightly
|
||||
|
||||
@@ -36,38 +36,19 @@ 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
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
node-version: "lts/*"
|
||||
bun-version: latest
|
||||
|
||||
# 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 install --include=optional
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
if: steps.webview-cache.outputs.cache-hit != 'true'
|
||||
run: cd webview-ui && npm install --include=optional
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Install Publishing Tools
|
||||
run: npm install -g @vscode/vsce ovsx
|
||||
run: bun add -g @vscode/vsce ovsx
|
||||
|
||||
- name: Get Version
|
||||
id: get_version
|
||||
@@ -109,29 +90,38 @@ jobs:
|
||||
vsce package --allow-package-secrets sendgrid --out "cline-${{ steps.get_version.outputs.version }}.vsix"
|
||||
|
||||
if [ "${{ github.event.inputs.release-type }}" = "pre-release" ]; then
|
||||
npm run publish:marketplace:prerelease
|
||||
bun run publish:marketplace:prerelease
|
||||
echo "Successfully published pre-release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
|
||||
else
|
||||
npm run publish:marketplace
|
||||
bun run publish:marketplace
|
||||
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 }}
|
||||
|
||||
+38
-112
@@ -24,35 +24,18 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
node-version: 22
|
||||
bun-version: latest
|
||||
|
||||
- name: Cache root dependencies
|
||||
uses: actions/cache@v4
|
||||
id: root-cache
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- name: Cache webview-ui dependencies
|
||||
uses: actions/cache@v4
|
||||
id: webview-cache
|
||||
with:
|
||||
path: webview-ui/node_modules
|
||||
key: ${{ runner.os }}-npm-webview-${{ hashFiles('webview-ui/package-lock.json') }}
|
||||
|
||||
- name: Install root dependencies
|
||||
if: steps.root-cache.outputs.cache-hit != 'true'
|
||||
run: npm ci
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
if: steps.webview-cache.outputs.cache-hit != 'true'
|
||||
run: cd webview-ui && npm ci
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Run Quality Checks (Parallel)
|
||||
run: npm run ci:check-all
|
||||
run: bun run ci:check-all
|
||||
|
||||
test:
|
||||
needs: quality-checks
|
||||
@@ -69,71 +52,54 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
node-version: 22
|
||||
bun-version: latest
|
||||
|
||||
- name: Cache root dependencies
|
||||
uses: actions/cache@v4
|
||||
id: root-cache
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- name: Cache webview-ui dependencies
|
||||
uses: actions/cache@v4
|
||||
id: webview-cache
|
||||
with:
|
||||
path: webview-ui/node_modules
|
||||
key: ${{ runner.os }}-npm-webview-${{ hashFiles('webview-ui/package-lock.json') }}
|
||||
|
||||
- name: Install root dependencies
|
||||
if: steps.root-cache.outputs.cache-hit != 'true'
|
||||
run: npm ci
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
if: steps.webview-cache.outputs.cache-hit != 'true'
|
||||
run: cd webview-ui && npm ci
|
||||
|
||||
- name: Set up NPM on Windows
|
||||
if: runner.os == 'Windows'
|
||||
run: |
|
||||
npm config set script-shell "C:\\Program Files\\Git\\bin\\bash.exe"
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Build the extension and tests (without redundant checks)
|
||||
- name: Build Tests and Extension
|
||||
id: build_step
|
||||
run: npm run ci:build
|
||||
run: bun run ci:build
|
||||
|
||||
- name: Unit Tests with coverage - Linux
|
||||
id: unit_tests_linux
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os == 'Linux' }}
|
||||
run: |
|
||||
npx nyc --nycrc-path .nycrc.unit.json --reporter=lcov npm run test:unit
|
||||
bunx nyc --nycrc-path .nycrc.unit.json --reporter=lcov bun run test:unit
|
||||
|
||||
- name: Unit Tests - Non-Linux
|
||||
id: unit_tests_non_linux
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os != 'Linux' }}
|
||||
run: |
|
||||
npm run test:unit
|
||||
bun run test:unit
|
||||
|
||||
- name: Extension Integration Tests - Linux
|
||||
id: integration_tests_linux
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os == 'Linux' }}
|
||||
run: xvfb-run -a npm run test:coverage
|
||||
run: xvfb-run -a bun run test:coverage
|
||||
|
||||
- name: Extension Integration Tests - Non-Linux
|
||||
id: integration_tests_non_linux
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os != 'Linux' }}
|
||||
run: npm run test:integration
|
||||
run: bun run test:integration
|
||||
|
||||
- name: Webview Tests with Coverage
|
||||
id: webview_tests
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
run: |
|
||||
cd webview-ui
|
||||
npm run test:coverage
|
||||
bun run test:coverage
|
||||
|
||||
- name: CLI Tests
|
||||
id: cli_tests
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
run: cd cli && bun run test:run
|
||||
|
||||
- name: Save Coverage Reports
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -152,68 +118,28 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Cache root dependencies
|
||||
uses: actions/cache@v4
|
||||
id: root-cache
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
bun-version: latest
|
||||
|
||||
- name: Cache webview-ui dependencies
|
||||
uses: actions/cache@v4
|
||||
id: webview-cache
|
||||
with:
|
||||
path: webview-ui/node_modules
|
||||
key: ${{ runner.os }}-npm-webview-${{ hashFiles('webview-ui/package-lock.json') }}
|
||||
|
||||
# Cache testing-platform dependencies
|
||||
- name: Cache testing-platform dependencies
|
||||
uses: actions/cache@v4
|
||||
id: testing-platform-cache
|
||||
with:
|
||||
path: testing-platform/node_modules
|
||||
key: ${{ runner.os }}-npm-testing-platform-${{ hashFiles('testing-platform/package-lock.json') }}
|
||||
|
||||
- name: Install root dependencies
|
||||
if: steps.root-cache.outputs.cache-hit != 'true'
|
||||
run: npm ci
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
if: steps.webview-cache.outputs.cache-hit != 'true'
|
||||
run: cd webview-ui && npm ci
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.24'
|
||||
cache-dependency-path: cli/go.sum
|
||||
|
||||
- name: Build CLI binaries
|
||||
run: npm run compile-cli-all-platforms
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Download ripgrep binaries
|
||||
run: npm run download-ripgrep
|
||||
run: bun run download-ripgrep
|
||||
|
||||
- name: Compile NPM package
|
||||
run: npm run compile-standalone-npm
|
||||
- name: Compile Standalone
|
||||
run: bun run compile-standalone
|
||||
|
||||
- name: Install testing platform dependencies
|
||||
if: steps.testing-platform-cache.outputs.cache-hit != 'true'
|
||||
run: cd testing-platform && npm ci
|
||||
run: cd testing-platform && bun install
|
||||
|
||||
- name: Running testing platform integration spec tests
|
||||
continue-on-error: true
|
||||
timeout-minutes: 7
|
||||
# Temporarily wrapping the test command to always return a neutral exit code.
|
||||
# This prevents the job from showing as failed and avoids distracting developers
|
||||
# until the integration tests are ready to be enforced.
|
||||
run: |
|
||||
npm run test:tp-orchestrator -- tests/specs/ --count=1 --coverage || true
|
||||
run: bun run test:tp-orchestrator -- tests/specs/ --count=1 --coverage
|
||||
|
||||
- name: Save Coverage Reports
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
name: Trigger Jetbrains Plugin <-> Cline Tests
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
pull_request_target:
|
||||
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,7 +31,39 @@ 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.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
|
||||
|
||||
- name: Trigger IntelliJ Plugin Integration Test
|
||||
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 }}" \
|
||||
@@ -34,20 +75,23 @@ jobs:
|
||||
{
|
||||
"event_type": "cline-pr-check",
|
||||
"client_payload": {
|
||||
"pr_number": "${{ github.event.number }}",
|
||||
"branch_name": "${{ github.head_ref }}",
|
||||
"pr_number": "$PR_NUMBER",
|
||||
"branch_name": $BRANCH_NAME,
|
||||
"action": "${{ github.event.action }}",
|
||||
"sha": "${{ github.event.pull_request.head.sha }}",
|
||||
"pr_title": ${{ toJSON(github.event.pull_request.title) }},
|
||||
"pr_url": "${{ github.event.pull_request.html_url }}"
|
||||
"sha": "$PR_SHA",
|
||||
"pr_title": $PR_TITLE,
|
||||
"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 }}: ${{ github.event.pull_request.title }}"
|
||||
echo " Branch: ${{ github.head_ref }}"
|
||||
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"
|
||||
|
||||
+16
@@ -8,12 +8,17 @@ tmp
|
||||
|
||||
.DS_Store
|
||||
.idea
|
||||
.husky/_/
|
||||
|
||||
# Package manager lock files (we use Bun, so bun.lockb is NOT ignored)
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
.clineignore
|
||||
.venv
|
||||
.actrc
|
||||
CLAUDE.local.md
|
||||
|
||||
webview-ui/src/**/*.js
|
||||
webview-ui/src/**/*.js.map
|
||||
@@ -27,11 +32,22 @@ coverage-unit
|
||||
|
||||
*evals.env
|
||||
.env
|
||||
.secrets
|
||||
.github/act/.secrets
|
||||
|
||||
.worktrees
|
||||
|
||||
## Generated files ##
|
||||
src/generated/
|
||||
src/shared/proto/
|
||||
webview-ui/src/services/grpc-client.ts
|
||||
*.tsbuildinfo
|
||||
|
||||
# E2E Tests
|
||||
test-results
|
||||
|
||||
/.github/act
|
||||
/pkg
|
||||
.secrets
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
Vendored
+91
-50
@@ -5,8 +5,12 @@
|
||||
"tasks": [
|
||||
{
|
||||
"label": "compile-standalone",
|
||||
"type": "npm",
|
||||
"script": "compile-standalone",
|
||||
"type": "shell",
|
||||
"command": "bun",
|
||||
"args": [
|
||||
"run",
|
||||
"compile-standalone"
|
||||
],
|
||||
"group": "build",
|
||||
"problemMatcher": [],
|
||||
"presentation": {
|
||||
@@ -14,9 +18,13 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "npm: protos",
|
||||
"type": "npm",
|
||||
"script": "protos",
|
||||
"label": "bun:protos",
|
||||
"type": "shell",
|
||||
"command": "bun",
|
||||
"args": [
|
||||
"run",
|
||||
"protos"
|
||||
],
|
||||
"problemMatcher": [],
|
||||
"isBackground": false,
|
||||
"presentation": {
|
||||
@@ -31,11 +39,11 @@
|
||||
{
|
||||
"label": "watch",
|
||||
"dependsOn": [
|
||||
"npm: protos",
|
||||
"npm: build:webview",
|
||||
"npm: dev:webview",
|
||||
"npm: watch:tsc",
|
||||
"npm: watch:esbuild"
|
||||
"bun:protos",
|
||||
"bun:build:webview",
|
||||
"bun:dev:webview",
|
||||
"bun:watch:tsc",
|
||||
"bun:watch:esbuild"
|
||||
],
|
||||
"presentation": {
|
||||
"reveal": "always"
|
||||
@@ -48,11 +56,11 @@
|
||||
{
|
||||
"label": "watch:test",
|
||||
"dependsOn": [
|
||||
"npm: protos",
|
||||
"npm: build:webview:test",
|
||||
"npm: dev:webview",
|
||||
"npm: watch:tsc",
|
||||
"npm: watch:esbuild:test"
|
||||
"bun:protos",
|
||||
"bun:build:webview:test",
|
||||
"bun:dev:webview",
|
||||
"bun:watch:tsc",
|
||||
"bun:watch:esbuild:test"
|
||||
],
|
||||
"presentation": {
|
||||
"reveal": "always"
|
||||
@@ -60,14 +68,18 @@
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "build:webview",
|
||||
"type": "shell",
|
||||
"command": "bun",
|
||||
"args": [
|
||||
"run",
|
||||
"build:webview"
|
||||
],
|
||||
"group": "build",
|
||||
"problemMatcher": [],
|
||||
"isBackground": true,
|
||||
"label": "npm: build:webview",
|
||||
"label": "bun:build:webview",
|
||||
"dependsOn": [
|
||||
"npm: protos"
|
||||
"bun:protos"
|
||||
],
|
||||
"presentation": {
|
||||
"group": "watch",
|
||||
@@ -80,14 +92,18 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "build:webview:test",
|
||||
"type": "shell",
|
||||
"command": "bun",
|
||||
"args": [
|
||||
"run",
|
||||
"build:webview:test"
|
||||
],
|
||||
"group": "build",
|
||||
"problemMatcher": [],
|
||||
"isBackground": true,
|
||||
"label": "npm: build:webview:test",
|
||||
"label": "bun:build:webview:test",
|
||||
"dependsOn": [
|
||||
"npm: protos"
|
||||
"bun:protos"
|
||||
],
|
||||
"presentation": {
|
||||
"group": "watch",
|
||||
@@ -101,8 +117,12 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "dev:webview",
|
||||
"type": "shell",
|
||||
"command": "bun",
|
||||
"args": [
|
||||
"run",
|
||||
"dev:webview"
|
||||
],
|
||||
"group": "build",
|
||||
"problemMatcher": [
|
||||
{
|
||||
@@ -122,9 +142,9 @@
|
||||
}
|
||||
],
|
||||
"isBackground": true,
|
||||
"label": "npm: dev:webview",
|
||||
"label": "bun:dev:webview",
|
||||
"dependsOn": [
|
||||
"npm: protos"
|
||||
"bun:protos"
|
||||
],
|
||||
"presentation": {
|
||||
"group": "watch",
|
||||
@@ -137,8 +157,12 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "watch:esbuild",
|
||||
"type": "shell",
|
||||
"command": "bun",
|
||||
"args": [
|
||||
"run",
|
||||
"watch:esbuild"
|
||||
],
|
||||
"group": "build",
|
||||
"problemMatcher": {
|
||||
"pattern": [
|
||||
@@ -160,9 +184,9 @@
|
||||
}
|
||||
},
|
||||
"isBackground": true,
|
||||
"label": "npm: watch:esbuild",
|
||||
"label": "bun:watch:esbuild",
|
||||
"dependsOn": [
|
||||
"npm: protos"
|
||||
"bun:protos"
|
||||
],
|
||||
"presentation": {
|
||||
"group": "watch",
|
||||
@@ -175,8 +199,12 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "watch:esbuild:test",
|
||||
"type": "shell",
|
||||
"command": "bun",
|
||||
"args": [
|
||||
"run",
|
||||
"watch:esbuild:test"
|
||||
],
|
||||
"group": "build",
|
||||
"problemMatcher": {
|
||||
"pattern": [
|
||||
@@ -198,9 +226,9 @@
|
||||
}
|
||||
},
|
||||
"isBackground": true,
|
||||
"label": "npm: watch:esbuild:test",
|
||||
"label": "bun:watch:esbuild:test",
|
||||
"dependsOn": [
|
||||
"npm: protos"
|
||||
"bun:protos"
|
||||
],
|
||||
"presentation": {
|
||||
"group": "watch",
|
||||
@@ -214,14 +242,18 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "watch:tsc",
|
||||
"type": "shell",
|
||||
"command": "bun",
|
||||
"args": [
|
||||
"run",
|
||||
"watch:tsc"
|
||||
],
|
||||
"group": "build",
|
||||
"problemMatcher": "$tsc-watch",
|
||||
"isBackground": true,
|
||||
"label": "npm: watch:tsc",
|
||||
"label": "bun:watch:tsc",
|
||||
"dependsOn": [
|
||||
"npm: protos"
|
||||
"bun:protos"
|
||||
],
|
||||
"presentation": {
|
||||
"group": "watch",
|
||||
@@ -229,12 +261,17 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "watch-tests",
|
||||
"type": "shell",
|
||||
"command": "bun",
|
||||
"args": [
|
||||
"run",
|
||||
"watch-tests"
|
||||
],
|
||||
"label": "bun:watch-tests",
|
||||
"problemMatcher": "$tsc-watch",
|
||||
"isBackground": true,
|
||||
"dependsOn": [
|
||||
"npm: protos"
|
||||
"bun:protos"
|
||||
],
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
@@ -245,9 +282,9 @@
|
||||
{
|
||||
"label": "tasks: watch-tests",
|
||||
"dependsOn": [
|
||||
"npm: protos",
|
||||
"npm: watch",
|
||||
"npm: watch-tests"
|
||||
"bun:protos",
|
||||
"bun:watch",
|
||||
"bun:watch-tests"
|
||||
],
|
||||
"problemMatcher": []
|
||||
},
|
||||
@@ -265,15 +302,19 @@
|
||||
"command": "rm -rf ${workspaceFolder}/dist/tmp/user && mkdir -p ${workspaceFolder}/dist/tmp/user"
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "storybook",
|
||||
"type": "shell",
|
||||
"command": "bun",
|
||||
"args": [
|
||||
"run",
|
||||
"storybook"
|
||||
],
|
||||
"group": "build",
|
||||
"problemMatcher": [],
|
||||
"isBackground": false,
|
||||
"label": "npm: storybook",
|
||||
"label": "bun:storybook",
|
||||
"dependsOn": [
|
||||
"npm: protos",
|
||||
"npm: build:webview"
|
||||
"bun:protos",
|
||||
"bun:build:webview"
|
||||
],
|
||||
"presentation": {
|
||||
"reveal": "always"
|
||||
|
||||
+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
|
||||
+282
@@ -1,5 +1,287 @@
|
||||
# Changelog
|
||||
|
||||
## [3.56.0]
|
||||
|
||||
### Added
|
||||
|
||||
- __CLI authentication:__ Added Vercel AI Gateway and Cline API key provider support for headless CI/automation workflows
|
||||
- __New model:__ Added Kimi-K2.5 model to Moonshot provider (262K context, image support, prompt caching)
|
||||
- __Prompt variant:__ Added Trinity Large prompt variant for improved tool-calling support
|
||||
- __OpenTelemetry:__ Added support for custom headers on metrics and logs endpoints
|
||||
- __Social links:__ Added community icons (X, Discord, GitHub, Reddit, LinkedIn) to the What's New modal
|
||||
|
||||
### Fixed
|
||||
|
||||
- __LiteLLM:__ Fixed thinking configuration not appearing for reasoning-capable models
|
||||
- __OpenTelemetry:__ Fixed endpoint path handling (no longer incorrectly appends `/v1/logs` or `/v1/metrics`) and ensured logs are sent regardless of VSCode telemetry settings
|
||||
- __CLI auth:__ Fixed `cline auth` displaying incorrect provider information after configuration
|
||||
|
||||
### Changed
|
||||
|
||||
- __Hooks:__ Hook scripts now run from the workspace repository root instead of filesystem root
|
||||
- __Default settings:__ Enabled multi-root workspaces, parallel tool calling, and skills by default; disabled strict plan mode by default
|
||||
- __Settings UI:__ Refreshed feature settings section with collapsible design
|
||||
|
||||
## [3.55.0]
|
||||
|
||||
- Add new model: Arcee Trinity Large Preview
|
||||
- Add new model: Moonshot Kimi K2.5
|
||||
- Add MCP prompts support - prompts from connected MCP servers now appear in slash command autocomplete as `/mcp:<server>:<prompt>`
|
||||
|
||||
## [3.54.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Native tool calls support for Ollama provider
|
||||
- Sonnet 4.5 is now the default Amazon Bedrock model id
|
||||
|
||||
### Fixed
|
||||
|
||||
- Prevent infinite retry loops when replace_in_file fails repeatedly. The system now detects repeated failures and provides better guidance to break out of retry cycles.
|
||||
- Skip diff error UI handling during streaming to prevent flickering. Error handling is deferred until streaming completes.
|
||||
- Strip notebook cell outputs when extracting text content from Jupyter notebooks, significantly reducing context size sent to the LLM.
|
||||
- Throttle diff view updates during streaming to reduce UI flickering and improve performance.
|
||||
|
||||
### Changed
|
||||
|
||||
- Removed Mistral's Devstral-2512 free from the free models list
|
||||
- Removed deprecated zai-glm-4.6 model from Cerebras provider
|
||||
|
||||
## [3.53.1]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Bug in responses API
|
||||
|
||||
## [3.53.0]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Removed grok model from free tier
|
||||
|
||||
## [3.52.0]
|
||||
|
||||
### Added
|
||||
|
||||
- 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.
|
||||
- Grok models are now moving out of free tier and into paid plans.
|
||||
- Introduces comprehensive Jupyter Notebook support for Cline, enabling AI-assisted editing of `.ipynb` files with full cell-level context awareness.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Bugs in DiffViewProvider for file editing
|
||||
- Ollama's recommended models to use correct identifiers
|
||||
|
||||
## [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
|
||||
|
||||
@@ -1,125 +1,3 @@
|
||||
# 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
|
||||
@.clinerules/cli.md
|
||||
|
||||
+38
-22
@@ -34,23 +34,38 @@ We also welcome contributions to our [documentation](https://github.com/cline/cl
|
||||
|
||||
### Local Development Instructions
|
||||
|
||||
1. Clone the repository _(Requires [git-lfs](https://git-lfs.com/))_:
|
||||
> **Note**: This project uses [Bun](https://bun.sh) as the package manager.
|
||||
|
||||
1. Install Bun (if you haven't already):
|
||||
```bash
|
||||
# macOS/Linux
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
|
||||
# Windows
|
||||
powershell -c "irm bun.sh/install.ps1 | iex"
|
||||
```
|
||||
|
||||
2. Clone the repository _(Requires [git-lfs](https://git-lfs.com/))_:
|
||||
```bash
|
||||
git clone https://github.com/cline/cline.git
|
||||
```
|
||||
2. Open the project in VSCode:
|
||||
|
||||
3. Open the project in VSCode:
|
||||
```bash
|
||||
code cline
|
||||
```
|
||||
3. Install the necessary dependencies for the extension and webview-gui:
|
||||
|
||||
4. Install dependencies (installs for all workspaces):
|
||||
```bash
|
||||
npm run install:all
|
||||
bun install
|
||||
```
|
||||
4. Generate Protocol Buffer files (required before first build):
|
||||
|
||||
5. Generate Protocol Buffer files (required before first build):
|
||||
```bash
|
||||
npm run protos
|
||||
bun run protos
|
||||
```
|
||||
5. Launch by pressing `F5` (or `Run`->`Start Debugging`) to open a new VSCode window with the extension loaded. (You may need to install the [esbuild problem matchers extension](https://marketplace.visualstudio.com/items?itemName=connor4312.esbuild-problem-matchers) if you run into issues building the project.)
|
||||
|
||||
6. Launch by pressing `F5` (or `Run`->`Start Debugging`) to open a new VSCode window with the extension loaded. (You may need to install the [esbuild problem matchers extension](https://marketplace.visualstudio.com/items?itemName=connor4312.esbuild-problem-matchers) if you run into issues building the project.)
|
||||
|
||||
|
||||
|
||||
@@ -59,7 +74,7 @@ We also welcome contributions to our [documentation](https://github.com/cline/cl
|
||||
|
||||
1. Before creating a PR, generate a changeset entry:
|
||||
```bash
|
||||
npm run changeset
|
||||
bun run changeset
|
||||
```
|
||||
This will prompt you for:
|
||||
- Type of change (major, minor, patch)
|
||||
@@ -75,9 +90,10 @@ We also welcome contributions to our [documentation](https://github.com/cline/cl
|
||||
- Changesetbot will create a comment showing the version impact
|
||||
- When merged to main, changesetbot will create a Version Packages PR
|
||||
- When the Version Packages PR is merged, a new release will be published
|
||||
|
||||
4. Testing
|
||||
- Run `npm run test` to run tests locally.
|
||||
- Before submitting PR, run `npm run format:fix` to format your code
|
||||
- Run `bun run test` to run tests locally
|
||||
- Before submitting PR, run `bun run format:fix` to format your code
|
||||
|
||||
### Extension
|
||||
|
||||
@@ -88,12 +104,12 @@ We also welcome contributions to our [documentation](https://github.com/cline/cl
|
||||
- If you dismissed the prompts, you can install them manually from the Extensions panel
|
||||
|
||||
2. **Local Development**
|
||||
- Run `npm run install:all` to install dependencies
|
||||
- Run `npm run protos` to generate Protocol Buffer files (required before first build)
|
||||
- Run `npm run test` to run tests locally
|
||||
- Run `bun run install:all` to install dependencies
|
||||
- Run `bun run protos` to generate Protocol Buffer files (required before first build)
|
||||
- Run `bun run test` to run tests locally
|
||||
- Run → Start Debugging or `>Debug: Select and Start Debugging` and wait for a new VS Code instance to open
|
||||
- **Terminal Workflow**: Use `npm run dev` (generates protos + runs watch mode) or `npm run watch` (if protos already generated)
|
||||
- Before submitting PR, run `npm run format:fix` to format your code
|
||||
- **Terminal Workflow**: Use `bun run dev` (generates protos + runs watch mode) or `bun run watch` (if protos already generated)
|
||||
- Before submitting PR, run `bun run format:fix` to format your code
|
||||
|
||||
3. **Linux-specific Setup**
|
||||
VS Code extension tests on Linux require the following system libraries:
|
||||
@@ -149,8 +165,8 @@ Anyone can contribute code to Cline, but we ask that you follow these guidelines
|
||||
|
||||
2. **Code Quality**
|
||||
|
||||
- Run `npm run lint` to check code style
|
||||
- Run `npm run format` to automatically format code
|
||||
- Run `bun run lint` to check code style
|
||||
- Run `bun run format` to automatically format code
|
||||
- All PRs must pass CI checks which include both linting and formatting
|
||||
- Address any warnings or errors from linter before submitting
|
||||
- Follow TypeScript best practices and maintain type safety
|
||||
@@ -158,7 +174,7 @@ Anyone can contribute code to Cline, but we ask that you follow these guidelines
|
||||
3. **Testing**
|
||||
|
||||
- Add tests for new features
|
||||
- Run `npm test` to ensure all tests pass
|
||||
- Run `bun test` to ensure all tests pass
|
||||
- Update existing tests if your changes affect them
|
||||
- Include both unit tests and integration tests where appropriate
|
||||
|
||||
@@ -168,9 +184,9 @@ Anyone can contribute code to Cline, but we ask that you follow these guidelines
|
||||
|
||||
- **Running E2E tests:**
|
||||
```bash
|
||||
npm run test:e2e # Build and run all E2E tests
|
||||
npm run e2e # Run tests without rebuilding
|
||||
npm run test:e2e -- --debug # Run with interactive debugger
|
||||
bun run test:e2e # Build and run all E2E tests
|
||||
bun run e2e # Run tests without rebuilding
|
||||
bun run test:e2e -- --debug # Run with interactive debugger
|
||||
```
|
||||
|
||||
- **Writing E2E tests:**
|
||||
@@ -194,7 +210,7 @@ Anyone can contribute code to Cline, but we ask that you follow these guidelines
|
||||
|
||||
4. **Version Management with Changesets**
|
||||
|
||||
- Create a changeset for any user-facing changes using `npm run changeset`
|
||||
- Create a changeset for any user-facing changes using `bun run changeset`
|
||||
- Choose the appropriate version bump:
|
||||
- `major` for breaking changes (1.0.0 → 2.0.0)
|
||||
- `minor` for new features (1.0.0 → 1.1.0)
|
||||
|
||||
@@ -186,7 +186,7 @@
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2025 Cline Bot Inc.
|
||||
Copyright 2026 Cline Bot Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
||||
@@ -148,4 +148,4 @@ Get the same Cline experience with enterprise-grade controls: SSO (SAML/OIDC), g
|
||||
|
||||
## License
|
||||
|
||||
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
|
||||
[Apache 2.0 © 2026 Cline Bot Inc.](./LICENSE)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20.317 4.15557C18.7873 3.45369 17.147 2.93658 15.4319 2.6404C15.4007 2.63469 15.3695 2.64897 15.3534 2.67754C15.1424 3.05276 14.9087 3.54225 14.7451 3.927C12.9004 3.65083 11.0652 3.65083 9.25832 3.927C9.09465 3.5337 8.85248 3.05276 8.64057 2.67754C8.62449 2.64992 8.59328 2.63564 8.56205 2.6404C6.84791 2.93563 5.20756 3.45275 3.67693 4.15557C3.66368 4.16129 3.65233 4.17082 3.64479 4.18319C0.533392 8.83155 -0.31895 13.3657 0.0991801 17.8436C0.101072 17.8655 0.11337 17.8864 0.130398 17.8997C2.18321 19.4073 4.17171 20.3225 6.12328 20.9291C6.15451 20.9386 6.18761 20.9272 6.20748 20.9015C6.66913 20.2711 7.08064 19.6063 7.43348 18.9073C7.4543 18.8664 7.43442 18.8178 7.39186 18.8016C6.73913 18.554 6.1176 18.2521 5.51973 17.9093C5.47244 17.8816 5.46865 17.814 5.51216 17.7816C5.63797 17.6873 5.76382 17.5893 5.88396 17.4902C5.90569 17.4721 5.93598 17.4683 5.96153 17.4797C9.88928 19.273 14.1415 19.273 18.023 17.4797C18.0485 17.4674 18.0788 17.4712 18.1015 17.4893C18.2216 17.5883 18.3475 17.6873 18.4742 17.7816C18.5177 17.814 18.5149 17.8816 18.4676 17.9093C17.8697 18.2588 17.2482 18.554 16.5945 18.8006C16.552 18.8168 16.533 18.8664 16.5538 18.9073C16.9143 19.6054 17.3258 20.2701 17.7789 20.9005C17.7978 20.9272 17.8319 20.9386 17.8631 20.9291C19.8241 20.3225 21.8126 19.4073 23.8654 17.8997C23.8834 17.8864 23.8948 17.8664 23.8967 17.8445C24.3971 12.6676 23.0585 8.17064 20.3482 4.18414C20.3416 4.17082 20.3303 4.16129 20.317 4.15557ZM8.02002 15.117C6.8375 15.117 5.86313 14.0313 5.86313 12.6981C5.86313 11.3648 6.8186 10.2791 8.02002 10.2791C9.23087 10.2791 10.1958 11.3743 10.1769 12.6981C10.1769 14.0313 9.22141 15.117 8.02002 15.117ZM15.9947 15.117C14.8123 15.117 13.8379 14.0313 13.8379 12.6981C13.8379 11.3648 14.7933 10.2791 15.9947 10.2791C17.2056 10.2791 18.1705 11.3743 18.1516 12.6981C18.1516 14.0313 17.2056 15.117 15.9947 15.117Z" fill="#FAFAFA"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 24 24" fill="black" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 2C6.477 2 2 6.477 2 12C2 16.418 4.865 20.166 8.84 21.49C9.34 21.58 9.52 21.27 9.52 21C9.52 20.77 9.51 20.14 9.51 19.31C6.73 19.91 6.14 17.97 6.14 17.97C5.68 16.81 5.03 16.5 5.03 16.5C4.12 15.88 5.1 15.9 5.1 15.9C6.1 15.97 6.63 16.93 6.63 16.93C7.5 18.45 8.97 18 9.54 17.76C9.63 17.11 9.89 16.67 10.17 16.42C7.95 16.17 5.62 15.31 5.62 11.5C5.62 10.39 6 9.5 6.65 8.79C6.55 8.54 6.2 7.5 6.75 6.15C6.75 6.15 7.59 5.88 9.5 7.17C10.29 6.95 11.15 6.84 12 6.84C12.85 6.84 13.71 6.95 14.5 7.17C16.41 5.88 17.25 6.15 17.25 6.15C17.8 7.5 17.45 8.54 17.35 8.79C18 9.5 18.38 10.39 18.38 11.5C18.38 15.32 16.04 16.16 13.81 16.41C14.17 16.72 14.5 17.33 14.5 18.26C14.5 19.6 14.49 20.68 14.49 21C14.49 21.27 14.67 21.59 15.17 21.49C19.14 20.16 22 16.42 22 12C22 6.477 17.523 2 12 2Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 902 B |
@@ -0,0 +1,10 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_2001_1428)">
|
||||
<path d="M22.2234 0H1.77187C0.792187 0 0 0.773438 0 1.72969V22.2656C0 23.2219 0.792187 24 1.77187 24H22.2234C23.2031 24 24 23.2219 24 22.2703V1.72969C24 0.773438 23.2031 0 22.2234 0ZM7.12031 20.4516H3.55781V8.99531H7.12031V20.4516ZM5.33906 7.43438C4.19531 7.43438 3.27188 6.51094 3.27188 5.37187C3.27188 4.23281 4.19531 3.30937 5.33906 3.30937C6.47813 3.30937 7.40156 4.23281 7.40156 5.37187C7.40156 6.50625 6.47813 7.43438 5.33906 7.43438ZM20.4516 20.4516H16.8937V14.8828C16.8937 13.5562 16.8703 11.8453 15.0422 11.8453C13.1906 11.8453 12.9094 13.2937 12.9094 14.7891V20.4516H9.35625V8.99531H12.7687V10.5609H12.8156C13.2891 9.66094 14.4516 8.70938 16.1813 8.70938C19.7859 8.70938 20.4516 11.0813 20.4516 14.1656V20.4516Z" fill="#FAFAFA"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2001_1428">
|
||||
<rect width="24" height="24" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 989 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15.0512 4.07466C15.3113 5.17727 16.301 5.99866 17.4829 5.99866C18.8627 5.99866 19.9813 4.87965 19.9813 3.49933C19.9813 2.11902 18.8627 1 17.4829 1C16.2764 1 15.2703 1.85537 15.036 2.99314C13.0155 3.20991 11.4378 4.92417 11.4378 7.00167C11.4378 7.00636 11.4378 7.00988 11.4378 7.01456C9.24041 7.10713 7.23397 7.73284 5.641 8.72062C5.04949 8.26247 4.30688 7.98945 3.50102 7.98945C1.5672 7.98945 0 9.55725 0 11.4918C0 12.8955 0.824597 14.1048 2.01581 14.6637C2.13177 18.7297 6.56047 22 12.0082 22C17.4559 22 21.8905 18.7261 22.0006 14.6567C23.1824 14.0942 24 12.8885 24 11.493C24 9.55842 22.4328 7.99063 20.499 7.99063C19.6966 7.99063 18.9575 8.2613 18.3672 8.71594C16.7602 7.72113 14.7315 7.09541 12.5119 7.01222C12.5119 7.0087 12.5119 7.00636 12.5119 7.00285C12.5119 5.51473 13.6176 4.27971 15.0512 4.077V4.07466ZM5.50044 13.7146C5.559 12.4444 6.40234 11.4695 7.38272 11.4695C8.3631 11.4695 9.11274 12.4995 9.05417 13.7697C8.99561 15.0398 8.26354 15.5015 7.28199 15.5015C6.30044 15.5015 5.44187 14.9848 5.50044 13.7146ZM16.6348 11.4695C17.6164 11.4695 18.4597 12.4444 18.5171 13.7146C18.5757 14.9848 17.716 15.5015 16.7356 15.5015C15.7552 15.5015 15.022 15.041 14.9634 13.7697C14.9048 12.4995 15.6533 11.4695 16.6348 11.4695ZM15.4682 16.6533C15.6521 16.6721 15.7693 16.8631 15.6978 17.0341C15.0946 18.4766 13.6703 19.4901 12.0082 19.4901C10.3461 19.4901 8.92299 18.4766 8.31859 17.0341C8.24714 16.8631 8.36427 16.6721 8.54817 16.6533C9.62577 16.5444 10.7912 16.4846 12.0082 16.4846C13.2252 16.4846 14.3895 16.5444 15.4682 16.6533Z" fill="#FAFAFA"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18.3263 1.90393H21.6998L14.3297 10.3274L23 21.7899H16.2112L10.894 14.838L4.80995 21.7899H1.43443L9.31743 12.78L1 1.90393H7.96111L12.7674 8.25826L18.3263 1.90393ZM17.1423 19.7707H19.0116L6.94539 3.81706H4.93946L17.1423 19.7707Z" fill="#FAFAFA"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 358 B |
+32
-2
@@ -44,7 +44,7 @@
|
||||
"style": {
|
||||
"useNodejsImportProtocol": "off",
|
||||
"useImportType": "off",
|
||||
"useBlockStatements": "warn",
|
||||
"useBlockStatements": "off",
|
||||
"useNamingConvention": "off",
|
||||
"useThrowOnlyError": "info",
|
||||
"useConsistentArrayType": "off",
|
||||
@@ -124,7 +124,9 @@
|
||||
"!**/webview-ui/build",
|
||||
"!**/generated",
|
||||
"!**/proto",
|
||||
"!**/tests/specs"
|
||||
"!**/tests/specs",
|
||||
"!**/*.lock*",
|
||||
"!**/*-lock.json"
|
||||
]
|
||||
},
|
||||
"plugins": [
|
||||
@@ -147,6 +149,34 @@
|
||||
"src/dev/grit/vscode-api.grit"
|
||||
]
|
||||
},
|
||||
{
|
||||
// Do not use console logging directly, use the Logger service instead.
|
||||
"plugins": [
|
||||
"src/dev/grit/console-log.grit"
|
||||
],
|
||||
"includes": [
|
||||
"**",
|
||||
"!**/esbuild.*",
|
||||
"!**/*.mts",
|
||||
"!**/webview-ui/**",
|
||||
"!**/evals/**",
|
||||
"!**/standalone/**",
|
||||
"!**/cli/**",
|
||||
"!**/e2e/**",
|
||||
"!**/test/**",
|
||||
"!**/__tests__/**",
|
||||
"!**/*.test.ts",
|
||||
"!**/*.stories.ts",
|
||||
"!src/dev/**",
|
||||
"!**/*.mjs",
|
||||
"!**/*.js",
|
||||
"!**/scripts/**",
|
||||
"!**/*.tsx",
|
||||
"!**/testing-platform/**",
|
||||
// ACP mode must redirect console to stderr - this is intentional
|
||||
"!cli/src/acp/index.ts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"includes": [
|
||||
"**",
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
# Bun configuration for Cline monorepo
|
||||
|
||||
[install]
|
||||
# Use npm for package resolution compatibility
|
||||
registry = "https://registry.npmjs.org/"
|
||||
|
||||
# Cache configuration
|
||||
[install.cache]
|
||||
# Enable caching for faster installs
|
||||
disable = false
|
||||
|
||||
[install.scopes]
|
||||
# Configure scoped registries if needed
|
||||
# "@myorg" = "https://registry.example.com/"
|
||||
|
||||
[test]
|
||||
# Test configuration
|
||||
preload = []
|
||||
@@ -1,2 +0,0 @@
|
||||
cline-core-debug.log
|
||||
bin/*
|
||||
@@ -0,0 +1,365 @@
|
||||
# Cline CLI
|
||||
|
||||
The official CLI for Cline. Run Cline tasks directly from the terminal with the same underlying functionality as the VS Code extension.
|
||||
|
||||
## Features
|
||||
|
||||
- **Reuses Core Codebase**: Shares the same Controller, Task, and API handling as the VS Code extension
|
||||
- **Terminal Output**: Displays Cline messages directly in your terminal with colored output
|
||||
- **Task History**: Access your task history from the command line
|
||||
- **Configurable**: Use custom configuration directories and working directories
|
||||
- **Image Support**: Attach images to your prompts using file paths or inline references
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 20.x or later
|
||||
- bun or npm or yarn
|
||||
- The parent Cline project dependencies installed
|
||||
|
||||
## Installation
|
||||
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
# Install all dependencies first
|
||||
bun install
|
||||
|
||||
# Ensure protos are generated
|
||||
bun run protos
|
||||
|
||||
# Build and link the CLI globally
|
||||
bun run cli:link
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Interactive Mode (Default)
|
||||
|
||||
When you run `cline` without any command, it launches an interactive welcome prompt:
|
||||
|
||||
```bash
|
||||
# Launch interactive mode
|
||||
cline
|
||||
|
||||
# Or run a task directly
|
||||
cline "Create a hello world function in Python"
|
||||
|
||||
# With options
|
||||
cline -v --thinking "Analyze this codebase"
|
||||
```
|
||||
|
||||
### Commands
|
||||
|
||||
#### `task` (alias: `t`)
|
||||
|
||||
Run a new task with a prompt.
|
||||
|
||||
```bash
|
||||
cline task "Create a hello world function in Python"
|
||||
cline t "Create a hello world function"
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `-a, --act` | Run in act mode |
|
||||
| `-p, --plan` | Run in plan mode |
|
||||
| `-y, --yolo` | Enable yolo mode (auto-approve actions) |
|
||||
| `-m, --model <model>` | Model to use for the task |
|
||||
| `-i, --images <paths...>` | Image file paths to include with the task |
|
||||
| `-v, --verbose` | Show verbose output including reasoning |
|
||||
| `-c, --cwd <path>` | Working directory for the task |
|
||||
| `--config <path>` | Path to Cline configuration directory |
|
||||
| `-t, --thinking` | Enable extended thinking (1024 token budget) |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Run in plan mode with verbose output
|
||||
cline task -p -v "Design a REST API"
|
||||
|
||||
# Use a specific model with yolo mode
|
||||
cline task -m claude-sonnet-4-5-20250929 -y "Refactor this function"
|
||||
|
||||
# Include images with your prompt
|
||||
cline task -i screenshot.png diagram.jpg "Fix the UI based on these images"
|
||||
|
||||
# Or use inline image references in the prompt
|
||||
cline task "Fix the layout shown in @./screenshot.png"
|
||||
|
||||
# Enable extended thinking for complex tasks
|
||||
cline task -t "Architect a microservices system"
|
||||
|
||||
# Specify working directory
|
||||
cline task -c /path/to/project "Add unit tests"
|
||||
```
|
||||
|
||||
#### `history` (alias: `h`)
|
||||
|
||||
List task history with pagination support.
|
||||
|
||||
```bash
|
||||
cline history
|
||||
cline h
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `-n, --limit <number>` | Number of tasks to show (default: 10) |
|
||||
| `-p, --page <number>` | Page number, 1-based (default: 1) |
|
||||
| `--config <path>` | Path to Cline configuration directory |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Show last 10 tasks (default)
|
||||
cline history
|
||||
|
||||
# Show 20 tasks
|
||||
cline history -n 20
|
||||
|
||||
# Show page 2 with 5 tasks per page
|
||||
cline history -n 5 -p 2
|
||||
```
|
||||
|
||||
#### `config`
|
||||
|
||||
Show current configuration including global and workspace state.
|
||||
|
||||
```bash
|
||||
cline config
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--config <path>` | Path to Cline configuration directory |
|
||||
|
||||
#### `auth`
|
||||
|
||||
Authenticate a provider and configure what model is used.
|
||||
|
||||
```bash
|
||||
cline auth
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `-p, --provider <id>` | Provider ID for quick setup (e.g., openai-native, anthropic) |
|
||||
| `-k, --apikey <key>` | API key for the provider |
|
||||
| `-m, --modelid <id>` | Model ID to configure (e.g., gpt-4o, claude-sonnet-4-5-20250929) |
|
||||
| `-b, --baseurl <url>` | Base URL (optional, only for openai provider) |
|
||||
| `-v, --verbose` | Show verbose output |
|
||||
| `-c, --cwd <path>` | Working directory for the task |
|
||||
| `--config <path>` | Path to Cline configuration directory |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Interactive authentication
|
||||
cline auth
|
||||
|
||||
# Quick setup with provider and API key
|
||||
cline auth -p anthropic -k sk-ant-xxxxx
|
||||
|
||||
# Full quick setup with model
|
||||
cline auth -p openai-native -k sk-xxxxx -m gpt-4o
|
||||
|
||||
# OpenAI-compatible provider with custom base URL
|
||||
cline auth -p openai -k your-api-key -b https://api.example.com/v1
|
||||
```
|
||||
|
||||
### Global Options
|
||||
|
||||
These options are available for the default command (running a task directly):
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `-v, --verbose` | Show verbose output |
|
||||
| `-c, --cwd <path>` | Working directory |
|
||||
| `--config <path>` | Configuration directory |
|
||||
| `--thinking` | Enable extended thinking (1024 token budget) |
|
||||
|
||||
## Development
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Install all dependencies (root, webview-ui, cli)
|
||||
bun install
|
||||
|
||||
# 2. Build and link globally so you can run `cline` from anywhere
|
||||
bun run cli:link
|
||||
|
||||
# 3. Test it
|
||||
cline --help
|
||||
```
|
||||
|
||||
### Scripts
|
||||
|
||||
Run these from the repository root:
|
||||
|
||||
| Script | Description |
|
||||
|--------|-------------|
|
||||
| `bun install` | Install deps for root, webview-ui, and cli |
|
||||
| `bun run cli:build` | Generate protos and build CLI |
|
||||
| `bun run cli:build:production` | Production build (minified) |
|
||||
| `bun run cli:link` | Build and `bun link` so you can run `cline` from anywhere |
|
||||
| `bun run cli:unlink` | Remove the global `cline` symlink |
|
||||
| `bun run cli:dev` | Link + watch mode for development |
|
||||
| `bun run cli:watch` | Watch mode only (no initial build) |
|
||||
| `bun run cli:test` | Run CLI tests |
|
||||
|
||||
### Development Workflow
|
||||
|
||||
1. Run `bun run cli:dev` - this links the CLI globally and starts watch mode
|
||||
2. Make changes to files in `cli/src/`
|
||||
3. The build automatically rebuilds on save
|
||||
4. Test your changes by running `cline` in another terminal
|
||||
5. When done, run `bun run cli:unlink` to clean up
|
||||
|
||||
### Proto Generation
|
||||
|
||||
The CLI uses proto-generated types for message passing (same as the VS Code extension). If you modify any `.proto` files, run:
|
||||
|
||||
```bash
|
||||
bun run protos
|
||||
```
|
||||
|
||||
This generates TypeScript types in `src/generated/` that both the CLI and extension use.
|
||||
|
||||
## Publish
|
||||
|
||||
#### 1. Publish to npm
|
||||
```bash
|
||||
bun publish
|
||||
```
|
||||
|
||||
#### 2. Update the Homebrew formula
|
||||
```bash
|
||||
bun run update-brew-formula
|
||||
```
|
||||
|
||||
#### 3. Test the formula locally
|
||||
```bash
|
||||
# Create a local tap
|
||||
brew tap-new cline/local
|
||||
cp ./cli/cline.rb "$(brew --repository)/Library/Taps/cline/homebrew-local/Formula/cline.rb"
|
||||
|
||||
# Build from Source
|
||||
brew install --build-from-source cline/local/cline
|
||||
|
||||
# Install from your local tap
|
||||
brew install cline/local/cline
|
||||
|
||||
# Clean up when done
|
||||
brew untap cline/local
|
||||
```
|
||||
|
||||
#### 4. If using a tap, commit and push
|
||||
```bash
|
||||
git add cline.rb
|
||||
git commit -m "Update cline to v2.0.0"
|
||||
git push
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### How It Works
|
||||
|
||||
The CLI directly imports and reuses the core Cline TypeScript codebase (the same code that powers the VS Code extension). This means feature parity is easy to maintain - when core gets updated, the CLI automatically benefits.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ CLI (cli/) │
|
||||
│ - React Ink terminal UI │
|
||||
│ - Command parsing (commander) │
|
||||
│ - Terminal-specific adapters │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ direct imports
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Core (src/core/) │
|
||||
│ - Controller: task lifecycle, state management │
|
||||
│ - Task: AI API calls, tool execution │
|
||||
│ - StateManager: persistent storage │
|
||||
│ - Proto types: message definitions │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Unlike a client-server architecture, the CLI runs everything in a single Node.js process. The "host bridge" pattern provides terminal-appropriate implementations for things the VS Code extension would handle differently (clipboard, file dialogs, etc.).
|
||||
|
||||
### Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/index.ts` | Entry point, command definitions |
|
||||
| `src/components/App.tsx` | Main React Ink app |
|
||||
| `src/components/ChatView.tsx` | Task conversation UI |
|
||||
| `src/controllers/CliWebviewProvider.ts` | Bridges core messages to terminal output |
|
||||
| `src/vscode-context.ts` | Mock VS Code extension context for core compatibility |
|
||||
| `src/vscode-shim.ts` | Shims for VS Code APIs that core depends on |
|
||||
| `src/constants/colors.ts` | Terminal color definitions |
|
||||
|
||||
### React Ink
|
||||
|
||||
The CLI uses [React Ink](https://github.com/vadimdemedes/ink) for its terminal UI. This lets us build the interface with React components that render to the terminal. Key patterns:
|
||||
|
||||
- Components in `src/components/` render terminal UI
|
||||
- Hooks in `src/hooks/` manage terminal-specific state (size, scrolling)
|
||||
- The `useStateSubscriber` hook subscribes to core state changes
|
||||
|
||||
## Configuration
|
||||
|
||||
The CLI stores its data in `~/.cline/data/` by default:
|
||||
|
||||
- `globalState.json`: Global settings and state
|
||||
- `secrets.json`: API keys and secrets
|
||||
- `workspace/`: Workspace-specific state
|
||||
- `tasks/`: Task history and conversation data
|
||||
|
||||
Override with the `--config` option or `CLINE_DIR` environment variable.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Build Errors
|
||||
|
||||
If you encounter build errors:
|
||||
|
||||
```bash
|
||||
# Make sure all deps are installed
|
||||
bun install
|
||||
|
||||
# Regenerate proto types
|
||||
bun run protos
|
||||
|
||||
# Then rebuild
|
||||
bun run cli:build
|
||||
```
|
||||
|
||||
### "command not found: cline"
|
||||
|
||||
The CLI isn't linked globally. Run:
|
||||
|
||||
```bash
|
||||
bun run cli:link
|
||||
```
|
||||
|
||||
### Changes Not Reflected
|
||||
|
||||
If your code changes aren't showing up:
|
||||
|
||||
1. Make sure watch mode is running (`bun run cli:dev`)
|
||||
2. Check for TypeScript errors in the watch output
|
||||
3. Try unlinking and relinking: `bun run cli:unlink && bun run cli:link`
|
||||
|
||||
### Import Errors from Core
|
||||
|
||||
The CLI imports from `@core/`, `@shared/`, etc. These paths are defined in the root `tsconfig.json`. If you see import errors, make sure you're building from the repo root, not from inside `cli/`.
|
||||
+58
-48
@@ -1,72 +1,82 @@
|
||||
# Cline CLI
|
||||
# Cline
|
||||
|
||||
```
|
||||
/_____/\ /_/\ /_______/\/__/\ /__/\ /_____/\
|
||||
\:::__\/ \:\ \ \__.::._\/\::\_\\ \ \\::::_\/_
|
||||
\:\ \ __\:\ \ \::\ \ \:. `-\ \ \\:\/___/\
|
||||
\:\ \/_/\\:\ \____ _\::\ \__\:. _ \ \\::___\/_
|
||||
\:\_\ \ \\:\/___/\/__\::\__/\\. \`-\ \ \\:\____/\
|
||||
\_____\/ \_____\/\________\/ \__\/ \__\/ \_____\/
|
||||
```
|
||||
<p align="center">
|
||||
<img src="https://github.com/user-attachments/assets/7123f9d1-afeb-48d5-93fa-e750dec0ebba" width="70%" />
|
||||
</p>
|
||||
|
||||
Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more.
|
||||
<div align="center">
|
||||
<table>
|
||||
<tbody>
|
||||
<td align="center">
|
||||
<a href="https://www.npmjs.com/package/cline" target="_blank"><strong>NPM</strong></a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://discord.gg/cline" target="_blank"><strong>Discord</strong></a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://www.reddit.com/r/cline/" target="_blank"><strong>r/cline</strong></a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop" target="_blank"><strong>Feature Requests</strong></a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://docs.cline.bot/getting-started/for-new-coders" target="_blank"><strong>Getting Started</strong></a>
|
||||
</td>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
## Installation
|
||||
Meet Cline, an AI assistant that lives in your terminal.
|
||||
|
||||
Install Cline globally using npm:
|
||||
Cline can handle complex software development tasks step-by-step. With tools that let him create & edit files, explore large projects, use the browser, and execute terminal commands (after you grant permission), he can assist you in ways that go beyond code completion or tech support.
|
||||
|
||||
```bash
|
||||
npm install -g cline
|
||||
```
|
||||
npm i -g cline
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# cd into your project and run:
|
||||
cline
|
||||
```
|
||||
|
||||
This will start the Cline CLI interface where you can interact with the autonomous coding agent.
|
||||
> Move your mouse around under the Cline icon for a surprise!
|
||||
|
||||
## Features
|
||||
---
|
||||
|
||||
- **Autonomous Coding**: AI-powered code generation, editing, and refactoring
|
||||
- **File Operations**: Create, read, update, and delete files and directories
|
||||
- **Command Execution**: Run shell commands and scripts
|
||||
- **Browser Automation**: Interact with web pages and applications
|
||||
- **Multi-Model Support**: Works with Anthropic Claude, OpenAI GPT, and other AI models
|
||||
- **MCP Integration**: Extensible through Model Context Protocol servers
|
||||
- **Project Understanding**: Analyzes codebases to provide context-aware assistance
|
||||
<img align="right" width="340" src="https://github.com/user-attachments/assets/ceb74224-08aa-4b8b-a3e7-b438ac3d160a">
|
||||
|
||||
## Requirements
|
||||
### Use any API and Model
|
||||
|
||||
- Node.js 18.0.0 or higher
|
||||
- Supported platforms: macOS, Linux. Windows soon
|
||||
- Supported architectures: x64, arm64
|
||||
Cline supports API providers like ChatGPT, Anthropic, OpenAI, Google Gemini, AWS Bedrock, Azure, GCP Vertex, Cerebras and Groq. You can also configure any OpenAI compatible API, or use a local model through LM Studio/Ollama. If you're using a Cline Account, you'll always have access to the newest models as soon as they're available.
|
||||
|
||||
## Configuration
|
||||
<!-- Transparent pixel to create line break after floating image -->
|
||||
|
||||
Cline can be configured through:
|
||||
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
|
||||
|
||||
- Environment variables
|
||||
- Configuration files
|
||||
- Command-line arguments
|
||||
<img align="left" width="370" src="https://github.com/user-attachments/assets/cad091f6-6c0f-4e4b-97ea-a1ff67e39b9b">
|
||||
|
||||
See the [main documentation](https://cline.bot) for detailed configuration options.
|
||||
### Stay in Control with Human-in-the-Loop
|
||||
|
||||
## Links
|
||||
Cline asks for your approval before running commands, editing files, or taking any action. Review each step and approve or reject as you go—or enable auto-approve to let Cline work autonomously to completion.
|
||||
|
||||
- **Website**: [https://cline.bot](https://cline.bot)
|
||||
- **Documentation**: [https://docs.cline.bot](https://docs.cline.bot)
|
||||
- **GitHub**: [https://github.com/cline/cline](https://github.com/cline/cline)
|
||||
- **VSCode Extension**: Available in the VSCode Marketplace
|
||||
- **JetBrains Extension**: Available in the JetBrains Marketplace
|
||||
<!-- Transparent pixel to create line break after floating image -->
|
||||
|
||||
<img width="2000" height="0" src="https://github.com/user-attachments/assets/cad091f6-6c0f-4e4b-97ea-a1ff67e39b9b"><br>
|
||||
|
||||
<img align="right" width="400" src="https://github.com/user-attachments/assets/4f264a0c-3802-49a7-8e5e-13d97beb659e">
|
||||
|
||||
### Plan & Act Modes
|
||||
|
||||
Toggle to Plan Mode to discuss implementation and architecture with Cline. He'll ask clarifying questions, explore your codebase, and present a plan for you to align on. Once you're satisfied, switch to Act Mode and let Cline execute the plan.
|
||||
|
||||
<!-- Transparent pixel to create line break after floating image -->
|
||||
|
||||
<img width="2000" height="0" src="https://github.com/user-attachments/assets/4f264a0c-3802-49a7-8e5e-13d97beb659e"><br>
|
||||
|
||||
|
||||
## Enterprise
|
||||
|
||||
Get the same Cline experience with enterprise-grade controls: SSO (SAML/OIDC), global policies and configuration, observability with audit trails, private networking (VPC/private link), and self-hosted or on-prem deployments, and enterprise support. Learn more at our [enterprise page](https://cline.bot/enterprise) or [talk to us](https://cline.bot/contact-sales).
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0 - see [LICENSE](https://github.com/cline/cline/blob/main/LICENSE) for details.
|
||||
[Apache 2.0 © 2026 Cline Bot Inc.](./LICENSE)
|
||||
|
||||
## Support
|
||||
|
||||
- Report issues: [GitHub Issues](https://github.com/cline/cline/issues)
|
||||
- Community: [GitHub Discussions](https://github.com/cline/cline/discussions)
|
||||
- Documentation: [docs.cline.bot](https://docs.cline.bot)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# IMPORTANT: `npm run postpublish` to update this file after publishing a new version of the package
|
||||
class Cline < Formula
|
||||
desc "Autonomous coding agent CLI - capable of creating/editing files, running commands, and more"
|
||||
homepage "https://cline.bot"
|
||||
url "https://registry.npmjs.org/cline/-/cline-2.0.0.tgz" # GET from https://registry.npmjs.org/cline/latest tarball URL
|
||||
sha256 "65bae90401191aeeabfbbc0b315e816aea96742043ba85b90671bf5e19d0761e"
|
||||
license "Apache-2.0"
|
||||
|
||||
depends_on "node@20"
|
||||
depends_on "ripgrep"
|
||||
|
||||
def install
|
||||
system "npm", "install", *std_npm_args(prefix: false)
|
||||
bin.install_symlink Dir["#{libexec}/bin/*"]
|
||||
end
|
||||
|
||||
test do
|
||||
# Test that the binary exists and is executable
|
||||
assert_match version.to_s, shell_output("#{bin}/cline --version")
|
||||
end
|
||||
end
|
||||
@@ -1,71 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/cline/cli/pkg/hostbridge"
|
||||
)
|
||||
|
||||
var (
|
||||
port int
|
||||
verbose bool
|
||||
)
|
||||
|
||||
func main() {
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "cline-host",
|
||||
Short: "Cline Host Bridge Service",
|
||||
Long: `A simple host bridge service that provides host operations for Cline Core.`,
|
||||
RunE: runServer,
|
||||
}
|
||||
|
||||
rootCmd.Flags().IntVarP(&port, "port", "p", 51052, "port to listen on")
|
||||
rootCmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "verbose logging")
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func runServer(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
// Create gRPC hostbridge service
|
||||
service := hostbridge.NewGrpcServer(port, verbose)
|
||||
|
||||
// Handle graceful shutdown
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
go func() {
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sigChan
|
||||
|
||||
if verbose {
|
||||
log.Println("Shutting down hostbridge server...")
|
||||
}
|
||||
|
||||
cancel()
|
||||
}()
|
||||
|
||||
// Start server
|
||||
if verbose {
|
||||
log.Printf("Starting Cline Host Bridge on port %d", port)
|
||||
}
|
||||
|
||||
// Run the service
|
||||
if err := service.Start(ctx); err != nil {
|
||||
return fmt.Errorf("failed to run service: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,348 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/cline/cli/pkg/cli"
|
||||
"github.com/cline/cli/pkg/cli/auth"
|
||||
"github.com/cline/cli/pkg/cli/display"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/common"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
coreAddress string
|
||||
verbose bool
|
||||
outputFormat string
|
||||
|
||||
// Task creation flags (for root command)
|
||||
images []string
|
||||
files []string
|
||||
mode string
|
||||
settings []string
|
||||
yolo bool
|
||||
oneshot bool
|
||||
)
|
||||
|
||||
func main() {
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "cline [prompt]",
|
||||
Short: "Cline CLI - AI-powered coding assistant",
|
||||
Long: `A command-line interface for interacting with Cline AI coding assistant.
|
||||
|
||||
Start a new task by providing a prompt:
|
||||
cline "Create a new Python script that prints hello world"
|
||||
|
||||
Or pipe a prompt via stdin:
|
||||
echo "Create a todo app" | cline
|
||||
cat prompt.txt | cline --yolo
|
||||
|
||||
Or run with no arguments to enter interactive mode:
|
||||
cline
|
||||
|
||||
This CLI also provides task management, configuration, and monitoring capabilities.
|
||||
|
||||
For detailed documentation including all commands, options, and examples,
|
||||
see the manual page: man cline`,
|
||||
Args: cobra.ArbitraryArgs,
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
if outputFormat != "rich" && outputFormat != "json" && outputFormat != "plain" {
|
||||
return fmt.Errorf("invalid output format '%s': must be one of 'rich', 'json', or 'plain'", outputFormat)
|
||||
}
|
||||
|
||||
return global.InitializeGlobalConfig(&global.GlobalConfig{
|
||||
Verbose: verbose,
|
||||
OutputFormat: outputFormat,
|
||||
CoreAddress: coreAddress,
|
||||
})
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
var instanceAddress string
|
||||
|
||||
// If --address flag not provided, start instance BEFORE getting prompt
|
||||
if !cmd.Flags().Changed("address") {
|
||||
if global.Config.Verbose {
|
||||
fmt.Println("Starting new Cline instance...")
|
||||
}
|
||||
instance, err := global.Clients.StartNewInstance(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start new instance: %w", err)
|
||||
}
|
||||
instanceAddress = instance.Address
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("Started instance at %s\n\n", instanceAddress)
|
||||
}
|
||||
|
||||
// Set up cleanup on exit
|
||||
defer func() {
|
||||
if global.Config.Verbose {
|
||||
fmt.Println("\nCleaning up instance...")
|
||||
}
|
||||
registry := global.Clients.GetRegistry()
|
||||
if err := global.KillInstanceByAddress(context.Background(), registry, instanceAddress); err != nil {
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("Warning: Failed to clean up instance: %v\n", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Check if user has credentials configured
|
||||
if !isUserReadyToUse(ctx, instanceAddress) {
|
||||
// Create renderer for welcome messages
|
||||
renderer := display.NewRenderer(global.Config.OutputFormat)
|
||||
fmt.Printf("\n%s\n\n", renderer.Dim("Hey there! Looks like you're new here. Let's get you set up"))
|
||||
|
||||
if err := auth.HandleAuthMenuNoArgs(ctx); err != nil {
|
||||
// Check if user cancelled - exit cleanly
|
||||
if err == huh.ErrUserAborted {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("auth setup failed: %w", err)
|
||||
}
|
||||
|
||||
// Re-check after auth wizard
|
||||
if !isUserReadyToUse(ctx, instanceAddress) {
|
||||
return fmt.Errorf("credentials still not configured - please run 'cline auth' to complete setup")
|
||||
}
|
||||
|
||||
fmt.Printf("\n%s\n\n", renderer.Dim("✓ Setup complete, you can now use the Cline CLI"))
|
||||
}
|
||||
} else {
|
||||
// User specified --address flag, use that
|
||||
instanceAddress = coreAddress
|
||||
}
|
||||
|
||||
// Get content from both args and stdin
|
||||
prompt, err := getContentFromStdinAndArgs(args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read prompt: %w", err)
|
||||
}
|
||||
|
||||
// If no prompt from args or stdin, show interactive input
|
||||
if prompt == "" {
|
||||
// Pass the mode flag to banner so it shows correct mode
|
||||
prompt, err = promptForInitialTask(ctx, instanceAddress, mode)
|
||||
if err != nil {
|
||||
// Check if user cancelled - exit cleanly without error
|
||||
if err == huh.ErrUserAborted {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if prompt == "" {
|
||||
return fmt.Errorf("prompt required")
|
||||
}
|
||||
}
|
||||
|
||||
// If oneshot mode, force plan mode and yolo
|
||||
if oneshot {
|
||||
mode = "plan"
|
||||
yolo = true
|
||||
}
|
||||
|
||||
return cli.CreateAndFollowTask(ctx, prompt, cli.TaskOptions{
|
||||
Images: images,
|
||||
Files: files,
|
||||
Mode: mode,
|
||||
Settings: settings,
|
||||
Yolo: yolo,
|
||||
Address: instanceAddress,
|
||||
Verbose: verbose,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
rootCmd.PersistentFlags().StringVar(&coreAddress, "address", fmt.Sprintf("localhost:%d", common.DEFAULT_CLINE_CORE_PORT), "Cline Core gRPC address")
|
||||
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output")
|
||||
rootCmd.PersistentFlags().StringVarP(&outputFormat, "output-format", "F", "rich", "output format (rich|json|plain)")
|
||||
|
||||
// Task creation flags (only apply when using root command with prompt)
|
||||
rootCmd.Flags().StringSliceVarP(&images, "image", "i", nil, "attach image files")
|
||||
rootCmd.Flags().StringSliceVarP(&files, "file", "f", nil, "attach files")
|
||||
rootCmd.Flags().StringVarP(&mode, "mode", "m", "plan", "mode (act|plan) - defaults to plan")
|
||||
rootCmd.Flags().StringSliceVarP(&settings, "setting", "s", nil, "task settings (key=value format)")
|
||||
rootCmd.Flags().BoolVarP(&yolo, "yolo", "y", false, "enable yolo mode (non-interactive)")
|
||||
rootCmd.Flags().BoolVar(&yolo, "no-interactive", false, "enable yolo mode (non-interactive)")
|
||||
rootCmd.Flags().BoolVarP(&oneshot, "oneshot", "o", false, "full autonomous mode")
|
||||
|
||||
rootCmd.AddCommand(cli.NewTaskCommand())
|
||||
rootCmd.AddCommand(cli.NewInstanceCommand())
|
||||
rootCmd.AddCommand(cli.NewConfigCommand())
|
||||
rootCmd.AddCommand(cli.NewVersionCommand())
|
||||
rootCmd.AddCommand(cli.NewAuthCommand())
|
||||
rootCmd.AddCommand(cli.NewLogsCommand())
|
||||
// rootCmd.AddCommand(cli.NewDoctorCommand()) // Disabled for now
|
||||
|
||||
if err := rootCmd.ExecuteContext(context.Background()); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func promptForInitialTask(ctx context.Context, instanceAddress, modeFlag string) (string, error) {
|
||||
// Show session banner before the initial input
|
||||
showSessionBanner(ctx, instanceAddress, modeFlag)
|
||||
|
||||
var prompt string
|
||||
|
||||
// Create custom theme with mode-colored cursor and title
|
||||
theme := huh.ThemeCharm()
|
||||
|
||||
// Set cursor and title color based on mode
|
||||
modeColor := lipgloss.Color("3") // Yellow for plan
|
||||
if modeFlag == "act" {
|
||||
modeColor = lipgloss.Color("39") // Blue for act
|
||||
}
|
||||
|
||||
theme.Focused.TextInput.Cursor = theme.Focused.TextInput.Cursor.Foreground(modeColor)
|
||||
theme.Focused.Title = theme.Focused.Title.Foreground(modeColor)
|
||||
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewText().
|
||||
Title("Start a new Cline task").
|
||||
Description("What would you like Cline to help you with?").
|
||||
Placeholder("e.g., Create a REST API with authentication...").
|
||||
Lines(5).
|
||||
Value(&prompt),
|
||||
),
|
||||
).WithWidth(48).WithTheme(theme)
|
||||
|
||||
err := form.Run()
|
||||
if err != nil {
|
||||
// Check if user cancelled with Control-C
|
||||
if err == huh.ErrUserAborted {
|
||||
// Return a special error that indicates clean cancellation
|
||||
// This allows deferred cleanup to run
|
||||
return "", huh.ErrUserAborted
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
return strings.TrimSpace(prompt), nil
|
||||
}
|
||||
|
||||
// showSessionBanner displays session info before initial prompt
|
||||
func showSessionBanner(ctx context.Context, instanceAddress, modeFlag string) {
|
||||
bannerInfo := display.BannerInfo{
|
||||
Version: global.CliVersion,
|
||||
Mode: modeFlag, // Use the mode from command flag, not state
|
||||
}
|
||||
|
||||
// If mode is empty, default to "plan"
|
||||
if bannerInfo.Mode == "" {
|
||||
bannerInfo.Mode = "plan"
|
||||
}
|
||||
|
||||
// Get current working directory (this is what Cline will use)
|
||||
if cwd, err := os.Getwd(); err == nil {
|
||||
bannerInfo.Workdir = cwd
|
||||
}
|
||||
|
||||
// Get provider/model using auth functions (same logic as auth menu)
|
||||
manager, err := cli.NewTaskManagerForAddress(ctx, instanceAddress)
|
||||
if err == nil {
|
||||
if providerList, err := auth.GetProviderConfigurations(ctx, manager); err == nil {
|
||||
// Show provider/model for the mode we'll be using
|
||||
var providerDisplay *auth.ProviderDisplay
|
||||
if bannerInfo.Mode == "plan" && providerList.PlanProvider != nil {
|
||||
providerDisplay = providerList.PlanProvider
|
||||
} else if bannerInfo.Mode == "act" && providerList.ActProvider != nil {
|
||||
providerDisplay = providerList.ActProvider
|
||||
}
|
||||
|
||||
if providerDisplay != nil {
|
||||
bannerInfo.Provider = auth.GetProviderIDForEnum(providerDisplay.Provider)
|
||||
bannerInfo.ModelID = providerDisplay.ModelID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Render and display banner
|
||||
banner := display.RenderSessionBanner(bannerInfo)
|
||||
fmt.Println(banner)
|
||||
fmt.Println() // Extra spacing before form
|
||||
}
|
||||
|
||||
// isUserReadyToUse checks if the user has completed initial setup
|
||||
// Returns true if welcomeViewCompleted flag is set OR user is authenticated
|
||||
// Matches extension logic: welcomeViewCompleted = Boolean(globalState.welcomeViewCompleted || user?.uid)
|
||||
func isUserReadyToUse(ctx context.Context, instanceAddress string) bool {
|
||||
manager, err := cli.NewTaskManagerForAddress(ctx, instanceAddress)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Get state
|
||||
state, err := manager.GetClient().State.GetLatestState(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Parse state JSON
|
||||
stateMap := make(map[string]interface{})
|
||||
if err := json.Unmarshal([]byte(state.StateJson), &stateMap); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check 1: welcomeViewCompleted flag
|
||||
if welcomeCompleted, ok := stateMap["welcomeViewCompleted"].(bool); ok && welcomeCompleted {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check 2: Is user authenticated? (matches extension's || user?.uid check)
|
||||
if userInfo, ok := stateMap["userInfo"].(map[string]interface{}); ok {
|
||||
if uid, ok := userInfo["uid"].(string); ok && uid != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// getContentFromStdinAndArgs reads content from both command line args and stdin, and combines them
|
||||
func getContentFromStdinAndArgs(args []string) (string, error) {
|
||||
var content strings.Builder
|
||||
|
||||
// Add command line args first (if any)
|
||||
if len(args) > 0 {
|
||||
content.WriteString(strings.Join(args, " "))
|
||||
}
|
||||
|
||||
// Check if stdin has data
|
||||
stat, err := os.Stdin.Stat()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to stat stdin: %w", err)
|
||||
}
|
||||
|
||||
// Check if data is being piped to stdin
|
||||
if (stat.Mode() & os.ModeCharDevice) == 0 {
|
||||
// Only try to read if there's actually data available
|
||||
if stat.Size() > 0 {
|
||||
stdinBytes, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read from stdin: %w", err)
|
||||
}
|
||||
|
||||
stdinContent := strings.TrimSpace(string(stdinBytes))
|
||||
if stdinContent != "" {
|
||||
if content.Len() > 0 {
|
||||
content.WriteString(" ")
|
||||
}
|
||||
content.WriteString(stdinContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return content.String(), nil
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/cline/cli/pkg/common"
|
||||
)
|
||||
|
||||
// 2. Multi-instance start: default_instance remains the first started.
|
||||
func TestMultiInstanceDefaultUnchanged(t *testing.T) {
|
||||
_ = setTempClineDir(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Start first instance and wait healthy
|
||||
_ = mustRunCLI(ctx, t, "instance", "new")
|
||||
out1 := listInstancesJSON(ctx, t)
|
||||
if len(out1.CoreInstances) != 1 {
|
||||
t.Fatalf("expected 1 instance, got %d", len(out1.CoreInstances))
|
||||
}
|
||||
firstAddr := out1.CoreInstances[0].Address
|
||||
waitForAddressHealthy(t, firstAddr, defaultTimeout)
|
||||
|
||||
// Start second instance
|
||||
_ = mustRunCLI(ctx, t, "instance", "new")
|
||||
out2 := listInstancesJSON(ctx, t)
|
||||
if len(out2.CoreInstances) < 2 {
|
||||
t.Fatalf("expected at least 2 instances, got %d", len(out2.CoreInstances))
|
||||
}
|
||||
|
||||
// Default should remain the first started address
|
||||
if out2.DefaultInstance != firstAddr {
|
||||
t.Fatalf("default changed; expected %s, got %s", firstAddr, out2.DefaultInstance)
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Default.json update after removal of current default
|
||||
func TestDefaultJsonUpdateAfterRemoval(t *testing.T) {
|
||||
_ = setTempClineDir(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Start two instances
|
||||
_ = mustRunCLI(ctx, t, "instance", "new")
|
||||
_ = mustRunCLI(ctx, t, "instance", "new")
|
||||
|
||||
out := listInstancesJSON(ctx, t)
|
||||
if len(out.CoreInstances) < 2 {
|
||||
t.Fatalf("expected at least 2 instances, got %d", len(out.CoreInstances))
|
||||
}
|
||||
|
||||
// Choose second as new default
|
||||
target := out.CoreInstances[1]
|
||||
waitForAddressHealthy(t, target.Address, defaultTimeout)
|
||||
|
||||
// Set as default
|
||||
_ = mustRunCLI(ctx, t, "instance", "use", target.Address)
|
||||
|
||||
// Verify default switched
|
||||
out = listInstancesJSON(ctx, t)
|
||||
if out.DefaultInstance != target.Address {
|
||||
t.Fatalf("default_instance not updated to %s (got %s)", target.Address, out.DefaultInstance)
|
||||
}
|
||||
|
||||
// Kill the default instance using runtime PID discovery
|
||||
corePID := getCorePID(t, target.Address)
|
||||
if corePID <= 0 {
|
||||
t.Fatalf("could not find PID for core process at %s", target.Address)
|
||||
}
|
||||
t.Logf("Killing cline-core process PID %d for instance %s", corePID, target.Address)
|
||||
if err := syscall.Kill(corePID, syscall.SIGKILL); err != nil {
|
||||
t.Fatalf("kill pid %d: %v", corePID, err)
|
||||
}
|
||||
|
||||
// Wait for removal
|
||||
waitForAddressRemoved(t, target.Address, longTimeout)
|
||||
|
||||
// Clean up dangling host process (SIGKILL leaves these behind by design)
|
||||
t.Logf("Cleaning up dangling host process on port %d", target.HostPort())
|
||||
findAndKillHostProcess(t, target.HostPort())
|
||||
|
||||
// Ensure default_instance updated to another available instance (or removed if none remain)
|
||||
out = listInstancesJSON(ctx, t)
|
||||
|
||||
// If there are instances left, default_instance must be one of them
|
||||
if len(out.CoreInstances) > 0 {
|
||||
found := false
|
||||
for _, it := range out.CoreInstances {
|
||||
if out.DefaultInstance == it.Address {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("default_instance %s not set to an existing instance after removal", out.DefaultInstance)
|
||||
}
|
||||
} else {
|
||||
// No instances remain; cli-default-instance.json should be removed
|
||||
clineDir := getClineDir(t)
|
||||
defPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "settings", "cli-default-instance.json")
|
||||
if _, err := os.Stat(defPath); err == nil {
|
||||
t.Fatalf("expected cli-default-instance.json removed when no instances remain")
|
||||
}
|
||||
}
|
||||
|
||||
// Also verify cli-default-instance.json on disk reflects the in-memory default (if any)
|
||||
clineDir := getClineDir(t)
|
||||
defPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "settings", "cli-default-instance.json")
|
||||
if len(out.CoreInstances) > 0 {
|
||||
raw, err := os.ReadFile(defPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read cli-default-instance.json: %v", err)
|
||||
}
|
||||
var tmp struct {
|
||||
DefaultInstance string `json:"default_instance"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &tmp); err != nil {
|
||||
t.Fatalf("unmarshal cli-default-instance.json: %v", err)
|
||||
}
|
||||
if tmp.DefaultInstance != out.DefaultInstance {
|
||||
t.Fatalf("cli-default-instance.json mismatch: file=%s list=%s", tmp.DefaultInstance, out.DefaultInstance)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 11. SQLite database missing (edge): list succeeds and returns empty set
|
||||
func TestRegistryDirMissingEdge(t *testing.T) {
|
||||
clineDir := setTempClineDir(t)
|
||||
|
||||
// Remove the settings directory entirely (which contains locks.db)
|
||||
settingsDir := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER)
|
||||
if err := os.RemoveAll(settingsDir); err != nil {
|
||||
t.Fatalf("RemoveAll(%s): %v", common.SETTINGS_SUBFOLDER, err)
|
||||
}
|
||||
|
||||
// Listing should succeed and return empty results
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
|
||||
defer cancel()
|
||||
out := listInstancesJSON(ctx, t)
|
||||
if len(out.CoreInstances) != 0 {
|
||||
t.Fatalf("expected 0 instances after removing %s dir, got %d", common.SETTINGS_SUBFOLDER, len(out.CoreInstances))
|
||||
}
|
||||
|
||||
// Ensure cli-default-instance.json not present
|
||||
defPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "settings", "cli-default-instance.json")
|
||||
if _, err := os.Stat(defPath); err == nil {
|
||||
t.Fatalf("expected no cli-default-instance.json after removing %s dir", common.SETTINGS_SUBFOLDER)
|
||||
}
|
||||
}
|
||||
@@ -1,378 +0,0 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/common"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultTimeout = 30 * time.Second
|
||||
longTimeout = 60 * time.Second
|
||||
pollInterval = 250 * time.Millisecond
|
||||
instancesBinRel = "../bin/cline"
|
||||
)
|
||||
|
||||
func repoAwareBinPath(t *testing.T) string {
|
||||
// Tests live in repoRoot/cli/e2e. Binary is at repoRoot/cli/bin/cline
|
||||
t.Helper()
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("Getwd error: %v", err)
|
||||
}
|
||||
// cli/e2e -> cli/bin/cline
|
||||
p := filepath.Clean(filepath.Join(wd, instancesBinRel))
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
t.Fatalf("CLI binary not found at %s; run `npm run compile-cli` first: %v", p, err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func setTempClineDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
clineDir := filepath.Join(dir, ".cline")
|
||||
if err := os.MkdirAll(clineDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir clineDir: %v", err)
|
||||
}
|
||||
t.Setenv("CLINE_DIR", clineDir)
|
||||
return clineDir
|
||||
}
|
||||
|
||||
func runCLI(ctx context.Context, t *testing.T, args ...string) (string, string, int) {
|
||||
t.Helper()
|
||||
bin := repoAwareBinPath(t)
|
||||
|
||||
// Ensure CLI uses the same CLINE_DIR as the tests by passing --config=<CLINE_DIR>
|
||||
// (InitializeGlobalConfig uses ConfigPath as the base directory for registry.)
|
||||
if clineDir := os.Getenv("CLINE_DIR"); clineDir != "" && !contains(args, "--config") {
|
||||
// Prepend persistent flag so Cobra sees it regardless of subcommand position
|
||||
args = append([]string{"--config", clineDir}, args...)
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, bin, args...)
|
||||
// Run CLI from repo root so relative paths inside CLI (./cli/bin/...) resolve
|
||||
if wd, err := os.Getwd(); err == nil {
|
||||
repoRoot := filepath.Clean(filepath.Join(wd, "..", ".."))
|
||||
cmd.Dir = repoRoot
|
||||
}
|
||||
// propagate env including CLINE_DIR
|
||||
cmd.Env = os.Environ()
|
||||
outB, errB := &strings.Builder{}, &strings.Builder{}
|
||||
cmd.Stdout = outB
|
||||
cmd.Stderr = errB
|
||||
err := cmd.Run()
|
||||
exit := 0
|
||||
if err != nil {
|
||||
// Extract exit code if possible
|
||||
if ee, ok := err.(*exec.ExitError); ok {
|
||||
exit = ee.ExitCode()
|
||||
} else {
|
||||
exit = -1
|
||||
}
|
||||
}
|
||||
return outB.String(), errB.String(), exit
|
||||
}
|
||||
|
||||
func mustRunCLI(ctx context.Context, t *testing.T, args ...string) string {
|
||||
t.Helper()
|
||||
out, errOut, exit := runCLI(ctx, t, args...)
|
||||
if exit != 0 {
|
||||
t.Fatalf("cline %v failed (exit=%d)\nstdout:\n%s\nstderr:\n%s", args, exit, out, errOut)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func listInstancesJSON(ctx context.Context, t *testing.T) common.InstancesOutput {
|
||||
t.Helper()
|
||||
// Trigger CLI to perform cleanup/health by invoking list (table output is ignored)
|
||||
_ = mustRunCLI(ctx, t, "instance", "list")
|
||||
|
||||
// Read from SQLite locks database to build structured output
|
||||
clineDir := getClineDir(t)
|
||||
|
||||
// Load default instance from settings file
|
||||
defaultInstance := readDefaultInstanceFromSettings(t, clineDir)
|
||||
|
||||
// Load instances from SQLite
|
||||
instances := readInstancesFromSQLite(t, clineDir)
|
||||
|
||||
return common.InstancesOutput{
|
||||
DefaultInstance: defaultInstance,
|
||||
CoreInstances: instances,
|
||||
}
|
||||
}
|
||||
|
||||
func hasAddress(in common.InstancesOutput, addr string) bool {
|
||||
for _, it := range in.CoreInstances {
|
||||
if it.Address == addr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func getByAddress(in common.InstancesOutput, addr string) (common.CoreInstanceInfo, bool) {
|
||||
for _, it := range in.CoreInstances {
|
||||
if it.Address == addr {
|
||||
return it, true
|
||||
}
|
||||
}
|
||||
return common.CoreInstanceInfo{}, false
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, timeout time.Duration, cond func() (bool, string)) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
ok, msg := cond()
|
||||
if ok {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("waitFor timeout: %s", msg)
|
||||
}
|
||||
time.Sleep(pollInterval)
|
||||
}
|
||||
}
|
||||
|
||||
func waitForAddressHealthy(t *testing.T, addr string, timeout time.Duration) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
t.Logf("Waiting for gRPC health check on %s...", addr)
|
||||
|
||||
waitFor(t, timeout, func() (bool, string) {
|
||||
if common.IsInstanceHealthy(ctx, addr) {
|
||||
return true, ""
|
||||
}
|
||||
return false, fmt.Sprintf("gRPC health check failed for %s", addr)
|
||||
})
|
||||
|
||||
t.Logf("gRPC health check passed for %s", addr)
|
||||
}
|
||||
|
||||
func waitForAddressRemoved(t *testing.T, addr string, timeout time.Duration) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
waitFor(t, timeout, func() (bool, string) {
|
||||
out := listInstancesJSON(ctx, t)
|
||||
if hasAddress(out, addr) {
|
||||
return false, fmt.Sprintf("address %s still present", addr)
|
||||
}
|
||||
return true, ""
|
||||
})
|
||||
}
|
||||
|
||||
func findFreePort(t *testing.T) int {
|
||||
t.Helper()
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen 127.0.0.1:0: %v", err)
|
||||
}
|
||||
defer l.Close()
|
||||
_, portStr, _ := net.SplitHostPort(l.Addr().String())
|
||||
var port int
|
||||
fmt.Sscanf(portStr, "%d", &port)
|
||||
return port
|
||||
}
|
||||
|
||||
func getClineDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
clineDir := os.Getenv("CLINE_DIR")
|
||||
if clineDir == "" {
|
||||
t.Fatalf("CLINE_DIR not set")
|
||||
}
|
||||
return clineDir
|
||||
}
|
||||
|
||||
// isPortInUse checks if a port is currently in use by any process
|
||||
func isPortInUse(port int) bool {
|
||||
conn, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
|
||||
if err != nil {
|
||||
return true // Port is in use
|
||||
}
|
||||
conn.Close()
|
||||
return false // Port is free
|
||||
}
|
||||
|
||||
// waitForPortClosed waits for a port to become free (no process listening)
|
||||
func waitForPortClosed(t *testing.T, port int, timeout time.Duration) {
|
||||
t.Helper()
|
||||
waitFor(t, timeout, func() (bool, string) {
|
||||
if isPortInUse(port) {
|
||||
return false, fmt.Sprintf("port %d still in use", port)
|
||||
}
|
||||
return true, ""
|
||||
})
|
||||
}
|
||||
|
||||
// waitForPortsClosed waits for both core and host ports to become free
|
||||
func waitForPortsClosed(t *testing.T, corePort, hostPort int, timeout time.Duration) {
|
||||
t.Helper()
|
||||
waitFor(t, timeout, func() (bool, string) {
|
||||
if isPortInUse(corePort) {
|
||||
return false, fmt.Sprintf("core port %d still in use", corePort)
|
||||
}
|
||||
if isPortInUse(hostPort) {
|
||||
return false, fmt.Sprintf("host port %d still in use", hostPort)
|
||||
}
|
||||
return true, ""
|
||||
})
|
||||
}
|
||||
|
||||
// findAndKillHostProcess finds and kills any process listening on the host port
|
||||
// This is used to clean up dangling host processes after SIGKILL tests
|
||||
func findAndKillHostProcess(t *testing.T, hostPort int) {
|
||||
t.Helper()
|
||||
// Use lsof to find process listening on the host port
|
||||
cmd := exec.Command("lsof", "-ti", fmt.Sprintf(":%d", hostPort))
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
// No process found on port - that's fine
|
||||
return
|
||||
}
|
||||
|
||||
pidStr := strings.TrimSpace(string(output))
|
||||
if pidStr == "" {
|
||||
return
|
||||
}
|
||||
|
||||
var pid int
|
||||
if _, err := fmt.Sscanf(pidStr, "%d", &pid); err != nil {
|
||||
t.Logf("Warning: could not parse PID from lsof output: %s", pidStr)
|
||||
return
|
||||
}
|
||||
|
||||
if pid > 0 {
|
||||
t.Logf("Cleaning up dangling host process PID %d on port %d", pid, hostPort)
|
||||
if err := syscall.Kill(pid, syscall.SIGKILL); err != nil {
|
||||
t.Logf("Warning: failed to kill dangling host process %d: %v", pid, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getPIDByPort returns the PID of the process listening on the specified port (fallback method)
|
||||
func getPIDByPort(t *testing.T, port int) int {
|
||||
t.Helper()
|
||||
cmd := exec.Command("lsof", "-ti", fmt.Sprintf(":%d", port))
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return 0 // Process not found
|
||||
}
|
||||
|
||||
pidStr := strings.TrimSpace(string(output))
|
||||
if pidStr == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
pid, err := strconv.Atoi(pidStr)
|
||||
if err != nil {
|
||||
t.Logf("Warning: could not parse PID from lsof output: %s", pidStr)
|
||||
return 0
|
||||
}
|
||||
|
||||
return pid
|
||||
}
|
||||
|
||||
// getCorePIDViaRPC returns the PID of the cline-core process using RPC (preferred method)
|
||||
func getCorePIDViaRPC(t *testing.T, address string) int {
|
||||
t.Helper()
|
||||
|
||||
// Initialize global config to access registry
|
||||
clineDir := os.Getenv("CLINE_DIR")
|
||||
if clineDir == "" {
|
||||
t.Logf("Warning: CLINE_DIR not set, falling back to lsof")
|
||||
return getCorePIDViaLsof(t, address)
|
||||
}
|
||||
|
||||
cfg := &global.GlobalConfig{
|
||||
ConfigPath: clineDir,
|
||||
}
|
||||
|
||||
if err := global.InitializeGlobalConfig(cfg); err != nil {
|
||||
t.Logf("Warning: failed to initialize global config, falling back to lsof: %v", err)
|
||||
return getCorePIDViaLsof(t, address)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Get client for the address
|
||||
client, err := global.Clients.GetRegistry().GetClient(ctx, address)
|
||||
if err != nil {
|
||||
t.Logf("Warning: failed to get client for %s, falling back to lsof: %v", address, err)
|
||||
return getCorePIDViaLsof(t, address)
|
||||
}
|
||||
|
||||
// Call GetProcessInfo RPC
|
||||
processInfo, err := client.State.GetProcessInfo(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
t.Logf("Warning: GetProcessInfo RPC failed for %s, falling back to lsof: %v", address, err)
|
||||
return getCorePIDViaLsof(t, address)
|
||||
}
|
||||
|
||||
return int(processInfo.ProcessId)
|
||||
}
|
||||
|
||||
// getCorePIDViaLsof returns the PID using lsof (fallback method)
|
||||
func getCorePIDViaLsof(t *testing.T, address string) int {
|
||||
t.Helper()
|
||||
_, portStr, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
t.Logf("Warning: invalid address format %s", address)
|
||||
return 0
|
||||
}
|
||||
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
t.Logf("Warning: invalid port in address %s", address)
|
||||
return 0
|
||||
}
|
||||
|
||||
return getPIDByPort(t, port)
|
||||
}
|
||||
|
||||
// getCorePID returns the PID of the cline-core process for the given address
|
||||
// Uses RPC first, falls back to lsof if RPC fails
|
||||
func getCorePID(t *testing.T, address string) int {
|
||||
t.Helper()
|
||||
|
||||
// Try RPC first (preferred method)
|
||||
if pid := getCorePIDViaRPC(t, address); pid > 0 {
|
||||
return pid
|
||||
}
|
||||
|
||||
// Fall back to lsof if RPC fails
|
||||
return getCorePIDViaLsof(t, address)
|
||||
}
|
||||
|
||||
// getHostPID returns the PID of the cline-host process for the given host port
|
||||
func getHostPID(t *testing.T, hostPort int) int {
|
||||
t.Helper()
|
||||
return getPIDByPort(t, hostPort)
|
||||
}
|
||||
|
||||
// contains reports whether slice has the target string.
|
||||
func contains(slice []string, target string) bool {
|
||||
for _, s := range slice {
|
||||
if s == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestMain validates required artifacts exist before running E2E tests.
|
||||
// It does NOT build artifacts. Build manually via:
|
||||
//
|
||||
// npm run compile-standalone
|
||||
// npm run compile-cli
|
||||
func TestMain(m *testing.M) {
|
||||
// Determine repo root from cli/e2e
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "getwd: %v\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
repoRoot := filepath.Clean(filepath.Join(wd, "..", ".."))
|
||||
|
||||
cliBin := filepath.Join(repoRoot, "cli", "bin", "cline")
|
||||
coreJS := filepath.Join(repoRoot, "dist-standalone", "cline-core.js")
|
||||
|
||||
missing := []string{}
|
||||
if _, err := os.Stat(cliBin); err != nil {
|
||||
missing = append(missing, cliBin)
|
||||
}
|
||||
if _, err := os.Stat(coreJS); err != nil {
|
||||
missing = append(missing, coreJS)
|
||||
}
|
||||
|
||||
if len(missing) > 0 {
|
||||
if testing.Short() {
|
||||
// Optional quality-of-life: allow skipping with -short when artifacts are absent
|
||||
fmt.Fprintf(os.Stderr, "[e2e] skipping (-short) due to missing artifacts:\n %s\n", strings.Join(missing, "\n "))
|
||||
os.Exit(0)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "Missing required build artifacts for E2E tests:\n %s\n\nPlease build them first:\n npm run compile-standalone\n npm run compile-cli\n", strings.Join(missing, "\n "))
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/cline/cli/pkg/common"
|
||||
)
|
||||
|
||||
// 9. Mixed localhost vs 127.0.0.1 addresses coexist and are both healthy
|
||||
func TestMixedLocalhostVs127Coexist(t *testing.T) {
|
||||
clineDir := setTempClineDir(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Start one instance
|
||||
_ = mustRunCLI(ctx, t, "instance", "new")
|
||||
|
||||
// Get the running instance and its port/PID
|
||||
out := listInstancesJSON(ctx, t)
|
||||
if len(out.CoreInstances) == 0 {
|
||||
t.Fatalf("expected at least 1 instance")
|
||||
}
|
||||
inst := out.CoreInstances[0]
|
||||
waitForAddressHealthy(t, inst.Address, defaultTimeout)
|
||||
|
||||
// Manually add a SQLite entry for the same port but 127.0.0.1 host
|
||||
addr127 := fmt.Sprintf("127.0.0.1:%d", inst.CorePort())
|
||||
dbPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "locks.db")
|
||||
|
||||
if err := insertRemoteInstanceIntoSQLite(t, dbPath, addr127, inst.CorePort(), inst.HostPort()); err != nil {
|
||||
t.Fatalf("insert 127 alias entry: %v", err)
|
||||
}
|
||||
|
||||
// Verify both addresses appear and are healthy
|
||||
waitForAddressHealthy(t, inst.Address, defaultTimeout)
|
||||
waitForAddressHealthy(t, addr127, defaultTimeout)
|
||||
|
||||
out = listInstancesJSON(ctx, t)
|
||||
if !hasAddress(out, inst.Address) || !hasAddress(out, addr127) {
|
||||
t.Fatalf("expected both %s and %s present", inst.Address, addr127)
|
||||
}
|
||||
}
|
||||
|
||||
// 10. Start-stop stress: loop starting then killing instances; ensure no leftovers
|
||||
func TestStartStopStress(t *testing.T) {
|
||||
_ = setTempClineDir(t)
|
||||
|
||||
for i := 0; i < 3; i++ { // keep small for CI time
|
||||
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Snapshot current addresses
|
||||
before := listInstancesJSON(ctx, t)
|
||||
beforeSet := map[string]struct{}{}
|
||||
for _, it := range before.CoreInstances {
|
||||
beforeSet[it.Address] = struct{}{}
|
||||
}
|
||||
|
||||
// Start a new instance
|
||||
_ = mustRunCLI(ctx, t, "instance", "new")
|
||||
|
||||
// Find the new instance address
|
||||
var newAddr string
|
||||
waitFor(t, defaultTimeout, func() (bool, string) {
|
||||
after := listInstancesJSON(ctx, t)
|
||||
for _, it := range after.CoreInstances {
|
||||
if _, ok := beforeSet[it.Address]; !ok {
|
||||
newAddr = it.Address
|
||||
return true, ""
|
||||
}
|
||||
}
|
||||
return false, "new instance address not detected yet"
|
||||
})
|
||||
|
||||
// Wait healthy
|
||||
waitForAddressHealthy(t, newAddr, defaultTimeout)
|
||||
|
||||
// Get PID using runtime discovery and kill it
|
||||
after := listInstancesJSON(ctx, t)
|
||||
info, ok := getByAddress(after, newAddr)
|
||||
if !ok {
|
||||
t.Fatalf("new instance %s missing", newAddr)
|
||||
}
|
||||
|
||||
// Get PID using runtime discovery
|
||||
corePID := getCorePID(t, info.Address)
|
||||
if corePID <= 0 {
|
||||
t.Fatalf("could not find PID for new instance at %s", info.Address)
|
||||
}
|
||||
|
||||
t.Logf("Killing new instance %s (PID %d) for iteration %d", info.Address, corePID, i)
|
||||
if err := syscall.Kill(corePID, syscall.SIGKILL); err != nil {
|
||||
t.Fatalf("kill pid %d: %v", corePID, err)
|
||||
}
|
||||
|
||||
// Wait removed from SQLite database
|
||||
waitForAddressRemoved(t, newAddr, longTimeout)
|
||||
|
||||
// Verify instance is removed from SQLite database
|
||||
clineDir := os.Getenv("CLINE_DIR")
|
||||
if clineDir != "" {
|
||||
dbPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "locks.db")
|
||||
if verifyInstanceExistsInSQLite(t, dbPath, newAddr) {
|
||||
t.Fatalf("expected instance removed from SQLite database: %s", newAddr)
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up dangling host process (SIGKILL leaves these behind by design)
|
||||
t.Logf("Cleaning up dangling host process on port %d for iteration %d", info.HostPort(), i)
|
||||
findAndKillHostProcess(t, info.HostPort())
|
||||
|
||||
// Verify both ports are now free
|
||||
waitForPortsClosed(t, info.CorePort(), info.HostPort(), defaultTimeout)
|
||||
}
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/common"
|
||||
_ "github.com/glebarez/go-sqlite"
|
||||
"google.golang.org/grpc/health/grpc_health_v1"
|
||||
)
|
||||
|
||||
// readInstancesFromSQLite reads instances directly from the SQLite database for testing
|
||||
func readInstancesFromSQLite(t *testing.T, clineDir string) []common.CoreInstanceInfo {
|
||||
t.Helper()
|
||||
|
||||
dbPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "locks.db")
|
||||
|
||||
// Check if database exists
|
||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||
return []common.CoreInstanceInfo{}
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Logf("Warning: Failed to open SQLite database: %v", err)
|
||||
return []common.CoreInstanceInfo{}
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Query instance locks
|
||||
query := common.SelectInstanceLockHoldersAscSQL
|
||||
|
||||
rows, err := db.Query(query)
|
||||
if err != nil {
|
||||
t.Logf("Warning: Failed to query instance locks: %v", err)
|
||||
return []common.CoreInstanceInfo{}
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var instances []common.CoreInstanceInfo
|
||||
for rows.Next() {
|
||||
var heldBy, lockTarget string
|
||||
var lockedAt int64
|
||||
|
||||
err := rows.Scan(&heldBy, &lockTarget, &lockedAt)
|
||||
if err != nil {
|
||||
t.Logf("Warning: Failed to scan lock row: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Create InstanceInfo
|
||||
info := common.CoreInstanceInfo{
|
||||
Address: heldBy,
|
||||
HostServiceAddress: lockTarget,
|
||||
Status: grpc_health_v1.HealthCheckResponse_UNKNOWN, // Will be updated by health check
|
||||
LastSeen: time.Unix(lockedAt/1000, 0), // Convert from milliseconds
|
||||
}
|
||||
|
||||
instances = append(instances, info)
|
||||
}
|
||||
|
||||
return instances
|
||||
}
|
||||
|
||||
// readDefaultInstanceFromSettings reads the default instance from the settings file
|
||||
func readDefaultInstanceFromSettings(t *testing.T, clineDir string) string {
|
||||
t.Helper()
|
||||
|
||||
settingsPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "settings", "cli-default-instance.json")
|
||||
|
||||
data, err := os.ReadFile(settingsPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return ""
|
||||
}
|
||||
t.Logf("Warning: Failed to read default instance file: %v", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
var tmp struct {
|
||||
DefaultInstance string `json:"default_instance"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &tmp); err != nil {
|
||||
t.Logf("Warning: Failed to parse default instance file: %v", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
return tmp.DefaultInstance
|
||||
}
|
||||
|
||||
// insertRemoteInstanceIntoSQLite inserts a remote instance entry directly into SQLite for testing
|
||||
func insertRemoteInstanceIntoSQLite(t *testing.T, dbPath, address string, corePort, hostPort int) error {
|
||||
t.Helper()
|
||||
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Initialize database schema for testing
|
||||
createTableSQL := `
|
||||
CREATE TABLE IF NOT EXISTS locks (
|
||||
id INTEGER PRIMARY KEY,
|
||||
held_by TEXT NOT NULL,
|
||||
lock_type TEXT NOT NULL CHECK (lock_type IN ('file', 'instance', 'folder')),
|
||||
lock_target TEXT NOT NULL,
|
||||
locked_at INTEGER NOT NULL,
|
||||
UNIQUE(lock_type, lock_target)
|
||||
);
|
||||
`
|
||||
createIndexesSQL := `
|
||||
CREATE INDEX IF NOT EXISTS idx_locks_held_by ON locks(held_by);
|
||||
CREATE INDEX IF NOT EXISTS idx_locks_type ON locks(lock_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_locks_target ON locks(lock_target);
|
||||
`
|
||||
|
||||
if _, err := db.Exec(createTableSQL); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(createIndexesSQL); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Insert the remote instance
|
||||
hostAddress := "remote.example.com:0"
|
||||
if hostPort != 0 {
|
||||
hostAddress = "remote.example.com:" + strconv.Itoa(hostPort)
|
||||
}
|
||||
|
||||
insertSQL := `INSERT INTO locks (held_by, lock_type, lock_target, locked_at) VALUES (?, 'instance', ?, ?)`
|
||||
_, err = db.Exec(insertSQL, address, hostAddress, time.Now().Unix()*1000)
|
||||
return err
|
||||
}
|
||||
|
||||
// verifyInstanceExistsInSQLite checks if an instance exists in the SQLite database
|
||||
func verifyInstanceExistsInSQLite(t *testing.T, dbPath, address string) bool {
|
||||
t.Helper()
|
||||
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Logf("Failed to open database: %v", err)
|
||||
return false
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
query := `SELECT COUNT(*) FROM locks WHERE held_by = ? AND lock_type = 'instance'`
|
||||
var count int
|
||||
err = db.QueryRow(query, address).Scan(&count)
|
||||
if err != nil {
|
||||
t.Logf("Failed to query database: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
return count > 0
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"syscall"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestStartAndList verifies self-registration and default.json semantics in a fresh CLINE_DIR.
|
||||
func TestStartAndList(t *testing.T) {
|
||||
clineDir := setTempClineDir(t)
|
||||
t.Logf("Using temp CLINE_DIR: %s", clineDir)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
|
||||
defer cancel()
|
||||
|
||||
t.Logf("Starting new instance...")
|
||||
// Start a new instance
|
||||
startOutput := mustRunCLI(ctx, t, "instance", "new")
|
||||
t.Logf("Instance start output: %s", startOutput)
|
||||
|
||||
t.Logf("Listing instances to check registration...")
|
||||
// It should appear healthy in list JSON and be the default.
|
||||
out := listInstancesJSON(ctx, t)
|
||||
t.Logf("Found %d instances after start", len(out.CoreInstances))
|
||||
|
||||
if len(out.CoreInstances) != 1 {
|
||||
t.Fatalf("expected 1 instance, got %d", len(out.CoreInstances))
|
||||
}
|
||||
|
||||
addr := out.CoreInstances[0].Address
|
||||
t.Logf("Instance address: %s, status: %s", addr, out.CoreInstances[0].Status)
|
||||
|
||||
t.Logf("Waiting for address %s to become healthy...", addr)
|
||||
waitForAddressHealthy(t, addr, defaultTimeout)
|
||||
t.Logf("Address %s is now healthy", addr)
|
||||
|
||||
t.Logf("Checking default instance configuration...")
|
||||
// Default should be set to the new instance.
|
||||
out = listInstancesJSON(ctx, t)
|
||||
t.Logf("Default instance: %s", out.DefaultInstance)
|
||||
|
||||
if out.DefaultInstance == "" {
|
||||
t.Fatalf("default_instance not set")
|
||||
}
|
||||
if out.DefaultInstance != out.CoreInstances[0].Address {
|
||||
t.Fatalf("expected default_instance=%s, got %s", out.CoreInstances[0].Address, out.DefaultInstance)
|
||||
}
|
||||
|
||||
t.Logf("TestStartAndList completed successfully")
|
||||
}
|
||||
|
||||
// TestTaskNewDefault ensures tasks route to default instance.
|
||||
func TestTaskNewDefault(t *testing.T) {
|
||||
_ = setTempClineDir(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Start one instance and wait for healthy
|
||||
_ = mustRunCLI(ctx, t, "instance", "new")
|
||||
out := listInstancesJSON(ctx, t)
|
||||
if len(out.CoreInstances) != 1 {
|
||||
t.Fatalf("expected 1 instance, got %d", len(out.CoreInstances))
|
||||
}
|
||||
addr := out.CoreInstances[0].Address
|
||||
waitForAddressHealthy(t, addr, defaultTimeout)
|
||||
|
||||
// Create a new task at default (success is sufficient)
|
||||
_ = mustRunCLI(ctx, t, "task", "new", "hello world")
|
||||
}
|
||||
|
||||
// TestExplicitAddressAutoStart verifies that giving an explicit address auto-starts an instance and routes the task.
|
||||
func TestExplicitAddressAutoStart(t *testing.T) {
|
||||
_ = setTempClineDir(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Find a free port and use explicit address. This should auto-start an instance.
|
||||
port := findFreePort(t)
|
||||
addr := "localhost:" + itoa(port)
|
||||
|
||||
// Run a task at explicit address (auto-start path)
|
||||
_ = mustRunCLI(ctx, t, "task", "new", "--address", "localhost:"+itoa(port), "explicit address task")
|
||||
|
||||
// Verify the instance is present and healthy
|
||||
waitForAddressHealthy(t, addr, defaultTimeout)
|
||||
}
|
||||
|
||||
// TestCrashCleanup verifies that after SIGKILL of a local core, the cleanup removes the registry entry.
|
||||
// Also tests graceful shutdown (SIGTERM) vs crash cleanup and ensures no dangling host processes.
|
||||
func TestCrashCleanup(t *testing.T) {
|
||||
_ = setTempClineDir(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Start two instances for testing both graceful and crash scenarios
|
||||
_ = mustRunCLI(ctx, t, "instance", "new")
|
||||
_ = mustRunCLI(ctx, t, "instance", "new")
|
||||
|
||||
out := listInstancesJSON(ctx, t)
|
||||
if len(out.CoreInstances) < 2 {
|
||||
t.Fatalf("expected at least 2 instances, got %d", len(out.CoreInstances))
|
||||
}
|
||||
|
||||
// Test 1: Graceful shutdown (SIGTERM) - should clean up both processes
|
||||
gracefulTarget := out.CoreInstances[0]
|
||||
waitForAddressHealthy(t, gracefulTarget.Address, defaultTimeout)
|
||||
|
||||
// Get PID using runtime discovery
|
||||
gracefulPID := getCorePID(t, gracefulTarget.Address)
|
||||
if gracefulPID <= 0 {
|
||||
t.Fatalf("could not find PID for graceful target at %s", gracefulTarget.Address)
|
||||
}
|
||||
|
||||
t.Logf("Testing graceful shutdown (SIGTERM) for instance %s (PID %d)", gracefulTarget.Address, gracefulPID)
|
||||
if err := syscall.Kill(gracefulPID, syscall.SIGTERM); err != nil {
|
||||
t.Fatalf("kill SIGTERM pid %d: %v", gracefulPID, err)
|
||||
}
|
||||
|
||||
// Wait for registry cleanup
|
||||
waitForAddressRemoved(t, gracefulTarget.Address, longTimeout)
|
||||
|
||||
// Verify both core and host ports are freed (no dangling processes)
|
||||
waitForPortsClosed(t, gracefulTarget.CorePort(), gracefulTarget.HostPort(), defaultTimeout)
|
||||
|
||||
// Verify the instance is removed from SQLite (no file to check anymore)
|
||||
// The waitForAddressRemoved already confirms the instance is gone from the registry
|
||||
|
||||
// Test 2: Crash cleanup (SIGKILL) - creates dangling host process that we must clean up
|
||||
crashTarget := out.CoreInstances[1]
|
||||
waitForAddressHealthy(t, crashTarget.Address, defaultTimeout)
|
||||
|
||||
// Get PID using runtime discovery
|
||||
crashPID := getCorePID(t, crashTarget.Address)
|
||||
if crashPID <= 0 {
|
||||
t.Fatalf("could not find PID for crash target at %s", crashTarget.Address)
|
||||
}
|
||||
|
||||
t.Logf("Testing crash cleanup (SIGKILL) for instance %s (PID %d)", crashTarget.Address, crashPID)
|
||||
if err := syscall.Kill(crashPID, syscall.SIGKILL); err != nil {
|
||||
t.Fatalf("kill SIGKILL pid %d: %v", crashPID, err)
|
||||
}
|
||||
|
||||
// Wait for registry cleanup
|
||||
waitForAddressRemoved(t, crashTarget.Address, longTimeout)
|
||||
|
||||
// Verify the instance is removed from SQLite (no file to check anymore)
|
||||
// The waitForAddressRemoved already confirms the instance is gone from the registry
|
||||
|
||||
// Clean up dangling host process (SIGKILL leaves these behind by design)
|
||||
t.Logf("Cleaning up dangling host process %s", crashTarget.HostServiceAddress)
|
||||
findAndKillHostProcess(t, crashTarget.HostPort())
|
||||
|
||||
// Verify both ports are now free
|
||||
waitForPortsClosed(t, crashTarget.CorePort(), crashTarget.HostPort(), defaultTimeout)
|
||||
}
|
||||
|
||||
// itoa is a small helper for readability
|
||||
func itoa(i int) string {
|
||||
return strconvItoa(i)
|
||||
}
|
||||
|
||||
// minimal inline int->string to avoid extra imports in helpers
|
||||
func strconvItoa(i int) string {
|
||||
// simple fast path
|
||||
return fmtInt(i)
|
||||
}
|
||||
|
||||
func fmtInt(i int) string {
|
||||
// allocate small buffer; ints here are short
|
||||
return (func(n int) string {
|
||||
return fmt.Sprintf("%d", n)
|
||||
})(i)
|
||||
}
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import dotenv from "dotenv"
|
||||
import * as esbuild from "esbuild"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const rootDir = path.resolve(__dirname, "..")
|
||||
|
||||
// Load .env from repo root
|
||||
dotenv.config({ path: path.join(rootDir, ".env") })
|
||||
|
||||
const production = process.argv.includes("--production")
|
||||
const watch = process.argv.includes("--watch")
|
||||
|
||||
/**
|
||||
* Plugin to resolve path aliases from the parent project
|
||||
*/
|
||||
const aliasResolverPlugin: esbuild.Plugin = {
|
||||
name: "alias-resolver",
|
||||
setup(build) {
|
||||
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"),
|
||||
}
|
||||
|
||||
// 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 }
|
||||
}
|
||||
}
|
||||
|
||||
// Handle .js -> .ts extension mapping (common in ESM TypeScript projects)
|
||||
if (importPath.endsWith(".js")) {
|
||||
const tsPath = importPath.replace(/\.js$/, ".ts")
|
||||
if (fs.existsSync(tsPath)) {
|
||||
return { path: tsPath }
|
||||
}
|
||||
const tsxPath = importPath.replace(/\.js$/, ".tsx")
|
||||
if (fs.existsSync(tsxPath)) {
|
||||
return { path: tsxPath }
|
||||
}
|
||||
}
|
||||
|
||||
// If nothing worked, return the original path and let esbuild handle the error
|
||||
return { path: importPath }
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin to redirect vscode imports to our shim
|
||||
*/
|
||||
const vscodeStubPlugin: esbuild.Plugin = {
|
||||
name: "vscode-stub",
|
||||
setup(build) {
|
||||
// Redirect 'vscode' imports to our shim
|
||||
build.onResolve({ filter: /^vscode$/ }, () => {
|
||||
return { path: path.join(__dirname, "src", "vscode-shim.ts") }
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
const esbuildProblemMatcherPlugin: esbuild.Plugin = {
|
||||
name: "esbuild-problem-matcher",
|
||||
setup(build) {
|
||||
build.onStart(() => {
|
||||
console.log("[cli esbuild] 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("[cli esbuild] Build finished")
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// Plugin to stub out optional devtools module
|
||||
const stubOptionalModulesPlugin: esbuild.Plugin = {
|
||||
name: "stub-optional-modules",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^react-devtools-core$/ }, () => {
|
||||
return { path: path.join(__dirname, "src", "stub-devtools.js"), external: false }
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
const copyWasmFiles: esbuild.Plugin = {
|
||||
name: "copy-wasm-files",
|
||||
setup(build) {
|
||||
build.onEnd(() => {
|
||||
const destDir = path.join(__dirname, "dist")
|
||||
|
||||
// Ensure dist directory exists
|
||||
if (!fs.existsSync(destDir)) {
|
||||
fs.mkdirSync(destDir, { recursive: true })
|
||||
}
|
||||
|
||||
// tree sitter
|
||||
const sourceDir = path.join(rootDir, "node_modules", "web-tree-sitter")
|
||||
|
||||
// Copy tree-sitter.wasm
|
||||
const treeSitterWasm = path.join(sourceDir, "tree-sitter.wasm")
|
||||
if (fs.existsSync(treeSitterWasm)) {
|
||||
fs.copyFileSync(treeSitterWasm, path.join(destDir, "tree-sitter.wasm"))
|
||||
}
|
||||
|
||||
// Copy language-specific WASM files
|
||||
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(destDir, filename))
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
const buildEnvVars: Record<string, string> = {
|
||||
"process.env.IS_STANDALONE": JSON.stringify("true"),
|
||||
"process.env.IS_CLI": JSON.stringify("true"),
|
||||
}
|
||||
|
||||
const buildTimeEnvs = [
|
||||
"TELEMETRY_SERVICE_API_KEY",
|
||||
"ERROR_SERVICE_API_KEY",
|
||||
"POSTHOG_TELEMETRY_ENABLED",
|
||||
"OTEL_TELEMETRY_ENABLED",
|
||||
"OTEL_LOGS_EXPORTER",
|
||||
"OTEL_METRICS_EXPORTER",
|
||||
"OTEL_EXPORTER_OTLP_PROTOCOL",
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_HEADERS",
|
||||
"OTEL_METRIC_EXPORT_INTERVAL",
|
||||
"CLINE_ENVIRONMENT",
|
||||
]
|
||||
|
||||
buildTimeEnvs.forEach((envVar) => {
|
||||
if (process.env[envVar]) {
|
||||
console.log(`[cli esbuild] ${envVar} env var is set`)
|
||||
buildEnvVars[`process.env.${envVar}`] = JSON.stringify(process.env[envVar])
|
||||
}
|
||||
})
|
||||
|
||||
if (production) {
|
||||
buildEnvVars["process.env.IS_DEV"] = "false"
|
||||
}
|
||||
|
||||
const config: esbuild.BuildOptions = {
|
||||
entryPoints: [path.join(__dirname, "src", "index.ts")],
|
||||
bundle: true,
|
||||
minify: production,
|
||||
sourcemap: !production,
|
||||
logLevel: "silent",
|
||||
define: buildEnvVars,
|
||||
tsconfig: path.join(__dirname, "tsconfig.json"),
|
||||
plugins: [copyWasmFiles, aliasResolverPlugin, vscodeStubPlugin, stubOptionalModulesPlugin, esbuildProblemMatcherPlugin],
|
||||
format: "esm",
|
||||
sourcesContent: false,
|
||||
platform: "node",
|
||||
target: "node20",
|
||||
outfile: path.join(__dirname, "dist", "cli.mjs"),
|
||||
// These modules need to load files from the module directory at runtime
|
||||
external: [
|
||||
"@grpc/reflection",
|
||||
"grpc-health-check",
|
||||
"better-sqlite3",
|
||||
"ink",
|
||||
"ink-spinner",
|
||||
"ink-picture",
|
||||
"react",
|
||||
"aws4fetch",
|
||||
"pino",
|
||||
"pino-roll",
|
||||
"@vscode/ripgrep", // Uses __dirname to locate the binary
|
||||
],
|
||||
supported: { "top-level-await": true },
|
||||
banner: {
|
||||
js: `#!/usr/bin/env node
|
||||
// Suppress all Node.js warnings (deprecation, experimental, etc.)
|
||||
process.emitWarning = () => {};
|
||||
import { createRequire as _createRequire } from 'module';
|
||||
import { fileURLToPath as _fileURLToPath } from 'url';
|
||||
import { dirname as _dirname } from 'path';
|
||||
const require = _createRequire(import.meta.url);
|
||||
const __filename = _fileURLToPath(import.meta.url);
|
||||
const __dirname = _dirname(__filename);`,
|
||||
},
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const ctx = await esbuild.context(config)
|
||||
if (watch) {
|
||||
await ctx.watch()
|
||||
console.log("[cli] Watching for changes...")
|
||||
} else {
|
||||
await ctx.rebuild()
|
||||
await ctx.dispose()
|
||||
|
||||
// Make the output executable
|
||||
const outfile = path.join(__dirname, "dist", "cli.mjs")
|
||||
if (fs.existsSync(outfile)) {
|
||||
fs.chmodSync(outfile, "755")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
-64
@@ -1,64 +0,0 @@
|
||||
module github.com/cline/cli
|
||||
|
||||
go 1.23.0
|
||||
|
||||
require (
|
||||
github.com/atotto/clipboard v0.1.4
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7
|
||||
github.com/charmbracelet/bubbletea v1.3.6
|
||||
github.com/charmbracelet/glamour v0.10.0
|
||||
github.com/charmbracelet/huh v0.7.1-0.20251005153135-a01a1e304532
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
|
||||
github.com/cline/grpc-go v0.0.0
|
||||
github.com/glebarez/go-sqlite v1.22.0
|
||||
github.com/muesli/termenv v0.16.0
|
||||
github.com/spf13/cobra v1.8.0
|
||||
golang.org/x/term v0.32.0
|
||||
google.golang.org/grpc v1.75.0
|
||||
google.golang.org/protobuf v1.36.6
|
||||
)
|
||||
|
||||
replace github.com/cline/grpc-go => ../src/generated/grpc-go
|
||||
|
||||
require (
|
||||
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/catppuccin/go v0.3.0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/x/ansi v0.9.3 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/reflow v0.3.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/yuin/goldmark v1.7.8 // indirect
|
||||
github.com/yuin/goldmark-emoji v1.0.5 // indirect
|
||||
golang.org/x/net v0.41.0 // indirect
|
||||
golang.org/x/sync v0.15.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/text v0.26.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect
|
||||
modernc.org/libc v1.37.6 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.7.2 // indirect
|
||||
modernc.org/sqlite v1.28.0 // indirect
|
||||
)
|
||||
-162
@@ -1,162 +0,0 @@
|
||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
|
||||
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
|
||||
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
|
||||
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
|
||||
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
|
||||
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
|
||||
github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU=
|
||||
github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||
github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY=
|
||||
github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
|
||||
github.com/charmbracelet/huh v0.7.1-0.20251005153135-a01a1e304532 h1:+xmbw70JXxmsOqvm1PEIAqFnqI/Hy2RYqrK7CtPmsNY=
|
||||
github.com/charmbracelet/huh v0.7.1-0.20251005153135-a01a1e304532/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
||||
github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
|
||||
github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
|
||||
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
|
||||
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
||||
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
||||
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
|
||||
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
|
||||
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
|
||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
||||
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
||||
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 h1:pFyd6EwwL2TqFf8emdthzeX+gZE1ElRq3iM8pui4KBY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||
google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4=
|
||||
google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/libc v1.37.6 h1:orZH3c5wmhIQFTXF+Nt+eeauyd+ZIt2BX6ARe+kD+aw=
|
||||
modernc.org/libc v1.37.6/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE=
|
||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
|
||||
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
|
||||
modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ=
|
||||
modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0=
|
||||
+320
-267
@@ -1,318 +1,367 @@
|
||||
.\" Automatically generated by Pandoc 3.8.2
|
||||
.\" Automatically generated by Pandoc 3.8.3
|
||||
.\"
|
||||
.TH "CLINE" "1" "January 2025" "Cline CLI 1.0" "User Commands"
|
||||
.TH "CLINE" "1" "January 2026" "Cline CLI 2.0" "User Commands"
|
||||
.SH NAME
|
||||
cline \- orchestrate and interact with Cline AI coding agents
|
||||
cline \- AI coding assistant in your terminal
|
||||
.SH SYNOPSIS
|
||||
\f[B]cline\f[R] [\f[I]prompt\f[R]] [\f[I]options\f[R]]
|
||||
.PP
|
||||
\f[B]cline\f[R] \f[I]command\f[R] [\f[I]subcommand\f[R]]
|
||||
[\f[I]options\f[R]] [\f[I]arguments\f[R]]
|
||||
\f[B]cline\f[R] \f[I]command\f[R] [\f[I]options\f[R]]
|
||||
[\f[I]arguments\f[R]]
|
||||
.SH DESCRIPTION
|
||||
Try: cat README.md | cline \(lqSummarize this for me:\(rq
|
||||
\f[B]cline\f[R] is a command\-line interface for the Cline AI coding
|
||||
assistant.
|
||||
It provides the same powerful AI capabilities as the VS Code extension,
|
||||
directly in your terminal.
|
||||
.PP
|
||||
\f[B]cline\f[R] is a command\-line interface for orchestrating multiple
|
||||
Cline AI coding agents.
|
||||
Cline is an autonomous AI agent who can read, write, and execute code
|
||||
Cline is an autonomous AI agent that can read, write, and execute code
|
||||
across your projects.
|
||||
He operates through a client\-server architecture where \f[B]Cline
|
||||
Core\f[R] runs as a standalone service, and the CLI acts as a scriptable
|
||||
interface for managing tasks, instances, and agent interactions.
|
||||
He can create and edit files, run terminal commands, use a headless
|
||||
browser, and more\(emall while asking for your approval before taking
|
||||
actions.
|
||||
.PP
|
||||
The CLI is designed for both interactive use and automation, making it
|
||||
ideal for CI/CD pipelines, parallel task execution, and terminal\-based
|
||||
workflows.
|
||||
Multiple frontends (CLI, VSCode, JetBrains) can attach to the same Cline
|
||||
Core instance, enabling seamless task handoff between environments.
|
||||
The CLI supports both interactive mode (with a rich terminal UI) and
|
||||
plain text mode (for piped input and scripted workflows).
|
||||
.SH MODES OF OPERATION
|
||||
.TP
|
||||
\f[B]Instant Task Mode\f[R]
|
||||
The simplest invocation: \f[B]cline \(lqprompt here\(rq\f[R] immediately
|
||||
spawns an instance, creates a task, and enters chat mode.
|
||||
This is equivalent to running \f[B]cline instance new && cline task new
|
||||
&& cline task chat\f[R] in sequence.
|
||||
.TP
|
||||
\f[B]Subcommand Mode\f[R]
|
||||
Advanced usage with explicit control: \f[B]cline <command> [subcommand]
|
||||
[options]\f[R] provides fine\-grained control over instances, tasks,
|
||||
authentication, and configuration.
|
||||
\f[B]Interactive Mode\f[R] : When you run \f[B]cline\f[R] without
|
||||
arguments, it launches an interactive welcome prompt with a rich
|
||||
terminal UI.
|
||||
You can type your task, view conversation history, and interact with
|
||||
Cline in real\-time.
|
||||
.PP
|
||||
\f[B]Task Mode\f[R] : Run \f[B]cline \(lqprompt\(rq\f[R] or \f[B]cline
|
||||
task \(lqprompt\(rq\f[R] to immediately start a task.
|
||||
If stdin is a TTY, you\(cqll see the interactive UI.
|
||||
If stdin is piped or output is redirected, the CLI automatically
|
||||
switches to plain text mode.
|
||||
.PP
|
||||
\f[B]Plain Text Mode\f[R] : Activated automatically when stdin is piped,
|
||||
output is redirected, or \f[B]\-\-json\f[R]/\f[B]\-\-yolo\f[R] flags are
|
||||
used.
|
||||
Outputs clean text without the Ink UI, suitable for scripting and CI/CD
|
||||
pipelines.
|
||||
.SH AGENT BEHAVIOR
|
||||
Cline operates in two primary modes:
|
||||
.TP
|
||||
\f[B]ACT MODE\f[R]
|
||||
Cline actively uses tools to accomplish tasks.
|
||||
.PP
|
||||
\f[B]ACT MODE\f[R] : Cline actively uses tools to accomplish tasks.
|
||||
He can read files, write code, execute commands, use a headless browser,
|
||||
and more.
|
||||
This is the default mode for task execution.
|
||||
.TP
|
||||
\f[B]PLAN MODE\f[R]
|
||||
Cline gathers information and creates a detailed plan before
|
||||
implementation.
|
||||
.PP
|
||||
\f[B]PLAN MODE\f[R] : Cline gathers information and creates a detailed
|
||||
plan before implementation.
|
||||
He explores the codebase, asks clarifying questions, and presents a
|
||||
strategy for user approval before switching to ACT MODE.
|
||||
.SH INSTANT TASK OPTIONS
|
||||
When using the instant task syntax \f[B]cline \(lqprompt\(rq\f[R] the
|
||||
following options are available:
|
||||
.TP
|
||||
\f[B]\-o\f[R], \f[B]\-\-oneshot\f[R]
|
||||
Full autonomous mode.
|
||||
Cline completes the task and stops following after completion.
|
||||
Example: cline \-o \(lqwhat\(cqs 6 + 8?\(rq
|
||||
.TP
|
||||
\f[B]\-s\f[R], \f[B]\-\-setting\f[R] \f[I]setting\f[R] \f[I]value\f[R]
|
||||
Override a setting for this task
|
||||
.TP
|
||||
\f[B]\-y\f[R], \f[B]\-\-no\-interactive\f[R], \f[B]\-\-yolo\f[R]
|
||||
Enable fully autonomous mode.
|
||||
Disables all interactivity:
|
||||
.RS
|
||||
.IP \(bu 2
|
||||
ask_followup_question tool is disabled
|
||||
.IP \(bu 2
|
||||
attempt_completion happens automatically
|
||||
.IP \(bu 2
|
||||
execute_command runs in non\-blocking mode with timeout
|
||||
.IP \(bu 2
|
||||
PLAN MODE automatically switches to ACT MODE
|
||||
.RE
|
||||
.TP
|
||||
\f[B]\-m\f[R], \f[B]\-\-mode\f[R] \f[I]mode\f[R]
|
||||
Starting mode.
|
||||
Options: \f[B]act\f[R] (default), \f[B]plan\f[R]
|
||||
.SH GLOBAL OPTIONS
|
||||
These options apply to all subcommands:
|
||||
.TP
|
||||
\f[B]\-F\f[R], \f[B]\-\-output\-format\f[R] \f[I]format\f[R]
|
||||
Output format.
|
||||
Options: \f[B]rich\f[R] (default), \f[B]json\f[R], \f[B]plain\f[R]
|
||||
.TP
|
||||
\f[B]\-h\f[R], \f[B]\-\-help\f[R]
|
||||
Display help information for the command.
|
||||
.TP
|
||||
\f[B]\-v\f[R], \f[B]\-\-verbose\f[R]
|
||||
Enable verbose output for debugging.
|
||||
.SH COMMANDS
|
||||
.SS Authentication
|
||||
\f[B]cline auth\f[R] [\f[I]provider\f[R]] [\f[I]key\f[R]]
|
||||
.TP
|
||||
\f[B]cline a\f[R] [\f[I]provider\f[R]] [\f[I]key\f[R]]
|
||||
Configure authentication for AI model providers.
|
||||
Launches an interactive wizard if no arguments provided.
|
||||
If provider is specified without a key, prompts for the key or launches
|
||||
the appropriate OAuth flow.
|
||||
.SS Instance Management
|
||||
Cline Core instances are independent agent processes that can run in the
|
||||
background.
|
||||
Multiple instances can run simultaneously, enabling parallel task
|
||||
execution.
|
||||
.SS task (alias: t)
|
||||
Run a new task with a prompt.
|
||||
.PP
|
||||
\f[B]cline instance\f[R]
|
||||
.TP
|
||||
\f[B]cline i\f[R]
|
||||
Display instance management help.
|
||||
\f[B]cline task\f[R] \f[I]prompt\f[R] [\f[I]options\f[R]]
|
||||
.PP
|
||||
\f[B]cline instance new\f[R] [\f[B]\-d\f[R]|\f[B]\-\-default\f[R]]
|
||||
.TP
|
||||
\f[B]cline i n\f[R] [\f[B]\-d\f[R]|\f[B]\-\-default\f[R]]
|
||||
Spawn a new Cline Core instance.
|
||||
Use \f[B]\-\-default\f[R] to set it as the default instance for
|
||||
subsequent commands.
|
||||
.PP
|
||||
\f[B]cline instance list\f[R]
|
||||
.TP
|
||||
\f[B]cline i l\f[R]
|
||||
List all running Cline Core instances with their addresses and status.
|
||||
.PP
|
||||
\f[B]cline instance default\f[R] \f[I]address\f[R]
|
||||
.TP
|
||||
\f[B]cline i d\f[R] \f[I]address\f[R]
|
||||
Set the default instance to avoid specifying \f[B]\-\-address\f[R] in
|
||||
task commands.
|
||||
.PP
|
||||
\f[B]cline instance kill\f[R] \f[I]address\f[R]
|
||||
[\f[B]\-a\f[R]|\f[B]\-\-all\f[R]]
|
||||
.TP
|
||||
\f[B]cline i k\f[R] \f[I]address\f[R] [\f[B]\-a\f[R]|\f[B]\-\-all\f[R]]
|
||||
Terminate a Cline Core instance.
|
||||
Use \f[B]\-\-all\f[R] to kill all running instances.
|
||||
.SS Task Management
|
||||
Tasks represent individual work items that Cline executes.
|
||||
Tasks maintain conversation history, checkpoints, and settings.
|
||||
.PP
|
||||
\f[B]cline task\f[R] [\f[B]\-a\f[R]|\f[B]\-\-address\f[R]
|
||||
\f[I]ADDR\f[R]]
|
||||
.TP
|
||||
\f[B]cline t\f[R] [\f[B]\-a\f[R]|\f[B]\-\-address\f[R] \f[I]ADDR\f[R]]
|
||||
Display task management help.
|
||||
The \f[B]\-\-address\f[R] flag specifies which Cline Core instance to
|
||||
use (e.g., localhost:50052).
|
||||
.PP
|
||||
\f[B]cline task new\f[R] \f[I]prompt\f[R] [\f[I]options\f[R]]
|
||||
.TP
|
||||
\f[B]cline t n\f[R] \f[I]prompt\f[R] [\f[I]options\f[R]]
|
||||
Create a new task in the default or specified instance.
|
||||
\f[B]cline t\f[R] \f[I]prompt\f[R] [\f[I]options\f[R]] : Create and run
|
||||
a new task.
|
||||
Options:
|
||||
.RS
|
||||
.TP
|
||||
\f[B]\-s\f[R], \f[B]\-\-setting\f[R] \f[I]setting\f[R] \f[I]value\f[R]
|
||||
Set task\-specific settings
|
||||
.TP
|
||||
\f[B]\-y\f[R], \f[B]\-\-no\-interactive\f[R], \f[B]\-\-yolo\f[R]
|
||||
Enable autonomous mode
|
||||
.TP
|
||||
\f[B]\-m\f[R], \f[B]\-\-mode\f[R] \f[I]mode\f[R]
|
||||
Starting mode (act or plan)
|
||||
.RE
|
||||
.PP
|
||||
\f[B]cline task open\f[R] \f[I]task\-id\f[R] [\f[I]options\f[R]]
|
||||
.TP
|
||||
\f[B]cline t o\f[R] \f[I]task\-id\f[R] [\f[I]options\f[R]]
|
||||
Resume a previous task from history.
|
||||
Accepts the same options as \f[B]task new\f[R].
|
||||
\f[B]\-a\f[R], \f[B]\-\-act\f[R] : Run in act mode (default)
|
||||
.PP
|
||||
\f[B]cline task list\f[R]
|
||||
.TP
|
||||
\f[B]cline t l\f[R]
|
||||
List all tasks in history with their id and snippet
|
||||
\f[B]\-p\f[R], \f[B]\-\-plan\f[R] : Run in plan mode
|
||||
.PP
|
||||
\f[B]cline task chat\f[R]
|
||||
.TP
|
||||
\f[B]cline t c\f[R]
|
||||
Enter interactive chat mode for the current task.
|
||||
Allows back\-and\-forth conversation with Cline.
|
||||
\f[B]\-y\f[R], \f[B]\-\-yolo\f[R] : Enable yolo/yes mode (auto\-approve
|
||||
all actions, output in plain mode, exit process automatically when task
|
||||
complete)
|
||||
.PP
|
||||
\f[B]cline task send\f[R] [\f[I]message\f[R]] [\f[I]options\f[R]]
|
||||
.TP
|
||||
\f[B]cline t s\f[R] [\f[I]message\f[R]] [\f[I]options\f[R]]
|
||||
Send a message to Cline.
|
||||
If no message is provided, reads from stdin.
|
||||
\f[B]\-m\f[R], \f[B]\-\-model\f[R] \f[I]model\f[R] : Model to use for
|
||||
the task
|
||||
.PP
|
||||
\f[B]\-i\f[R], \f[B]\-\-images\f[R] \f[I]paths\&...\f[R] : Image file
|
||||
paths to include with the task
|
||||
.PP
|
||||
\f[B]\-v\f[R], \f[B]\-\-verbose\f[R] : Show verbose output including
|
||||
reasoning
|
||||
.PP
|
||||
\f[B]\-c\f[R], \f[B]\-\-cwd\f[R] \f[I]path\f[R] : Working directory for
|
||||
the task
|
||||
.PP
|
||||
\f[B]\-\-config\f[R] \f[I]path\f[R] : Path to Cline configuration
|
||||
directory
|
||||
.PP
|
||||
\f[B]\-\-thinking\f[R] : Enable extended thinking (1024 token budget)
|
||||
.PP
|
||||
\f[B]\-\-json\f[R] : Output messages as JSON instead of styled text
|
||||
.SS history (alias: h)
|
||||
List task history with pagination.
|
||||
.PP
|
||||
\f[B]cline history\f[R] [\f[I]options\f[R]]
|
||||
.PP
|
||||
\f[B]cline h\f[R] [\f[I]options\f[R]] : Display previous tasks.
|
||||
Options:
|
||||
.RS
|
||||
.TP
|
||||
\f[B]\-a\f[R], \f[B]\-\-approve\f[R]
|
||||
Approve Cline\(cqs proposed action
|
||||
.TP
|
||||
\f[B]\-d\f[R], \f[B]\-\-deny\f[R]
|
||||
Deny Cline\(cqs proposed action
|
||||
.TP
|
||||
\f[B]\-f\f[R], \f[B]\-\-file\f[R] \f[I]FILE\f[R]
|
||||
Attach a file to the message
|
||||
.TP
|
||||
\f[B]\-y\f[R], \f[B]\-\-no\-interactive\f[R], \f[B]\-\-yolo\f[R]
|
||||
Enable autonomous mode
|
||||
.TP
|
||||
\f[B]\-m\f[R], \f[B]\-\-mode\f[R] \f[I]mode\f[R]
|
||||
Switch mode (act or plan)
|
||||
.RE
|
||||
.PP
|
||||
\f[B]cline task view\f[R] [\f[B]\-f\f[R]|\f[B]\-\-follow\f[R]]
|
||||
[\f[B]\-c\f[R]|\f[B]\-\-follow\-complete\f[R]]
|
||||
.TP
|
||||
\f[B]cline t v\f[R] [\f[B]\-f\f[R]|\f[B]\-\-follow\f[R]] [\f[B]\-c\f[R]|\f[B]\-\-follow\-complete\f[R]]
|
||||
Display the current conversation.
|
||||
Use \f[B]\-\-follow\f[R] to stream updates in real\-time, or
|
||||
\f[B]\-\-follow\-complete\f[R] to follow until task completion.
|
||||
\f[B]\-n\f[R], \f[B]\-\-limit\f[R] \f[I]number\f[R] : Number of tasks to
|
||||
show (default: 10)
|
||||
.PP
|
||||
\f[B]cline task restore\f[R] \f[I]checkpoint\f[R]
|
||||
.TP
|
||||
\f[B]cline t r\f[R] \f[I]checkpoint\f[R]
|
||||
Restore the task to a previous checkpoint state.
|
||||
\f[B]\-p\f[R], \f[B]\-\-page\f[R] \f[I]number\f[R] : Page number,
|
||||
1\-based (default: 1)
|
||||
.PP
|
||||
\f[B]cline task pause\f[R]
|
||||
.TP
|
||||
\f[B]cline t p\f[R]
|
||||
Pause task execution.
|
||||
.SS Configuration
|
||||
Configuration can be set globally.
|
||||
Override these global settings for a task using the
|
||||
\f[B]\-\-setting\f[R] flag
|
||||
\f[B]\-\-config\f[R] \f[I]path\f[R] : Path to Cline configuration
|
||||
directory
|
||||
.SS config
|
||||
Show current configuration.
|
||||
.PP
|
||||
\f[B]cline config\f[R]
|
||||
\f[B]cline config\f[R] [\f[I]options\f[R]] : Display global and
|
||||
workspace state.
|
||||
Options:
|
||||
.PP
|
||||
\f[B]cline c\f[R]
|
||||
\f[B]\-\-config\f[R] \f[I]path\f[R] : Path to Cline configuration
|
||||
directory
|
||||
.SS auth
|
||||
Authenticate a provider and configure the model.
|
||||
.PP
|
||||
\f[B]cline config set\f[R] \f[I]key\f[R] \f[I]value\f[R]
|
||||
.TP
|
||||
\f[B]cline c s\f[R] \f[I]key\f[R] \f[I]value\f[R]
|
||||
Set a configuration variable.
|
||||
\f[B]cline auth\f[R] [\f[I]options\f[R]] : Launch interactive
|
||||
authentication wizard, or use quick setup flags.
|
||||
Options:
|
||||
.PP
|
||||
\f[B]cline config get\f[R] \f[I]key\f[R]
|
||||
.TP
|
||||
\f[B]cline c g\f[R] \f[I]key\f[R]
|
||||
Read a configuration variable.
|
||||
\f[B]\-p\f[R], \f[B]\-\-provider\f[R] \f[I]id\f[R] : Provider ID for
|
||||
quick setup (e.g., openai\-native, anthropic, openrouter)
|
||||
.PP
|
||||
\f[B]cline config list\f[R]
|
||||
.TP
|
||||
\f[B]cline c l\f[R]
|
||||
List all configuration variables and their values.
|
||||
.SH TASK SETTINGS
|
||||
Task settings are persisted in the \f[I]\(ti/.cline/x/tasks\f[R]
|
||||
directory.
|
||||
When resuming a task with \f[B]cline task open\f[R], task settings are
|
||||
automatically restored.
|
||||
\f[B]\-k\f[R], \f[B]\-\-apikey\f[R] \f[I]key\f[R] : API key for the
|
||||
provider
|
||||
.PP
|
||||
Common settings include:
|
||||
.TP
|
||||
\f[B]yolo\f[R]
|
||||
Enable autonomous mode (true/false)
|
||||
.TP
|
||||
\f[B]mode\f[R]
|
||||
Starting mode (act/plan)
|
||||
.SH NOTES & EXAMPLES
|
||||
The \f[B]cline task send\f[R] and \f[B]cline task new\f[R] commands
|
||||
support reading from stdin, enabling powerful pipeline compositions:
|
||||
\f[B]\-m\f[R], \f[B]\-\-modelid\f[R] \f[I]id\f[R] : Model ID to
|
||||
configure (e.g., gpt\-4o, claude\-sonnet\-4\-5\-20250929)
|
||||
.PP
|
||||
\f[B]\-b\f[R], \f[B]\-\-baseurl\f[R] \f[I]url\f[R] : Base URL (optional,
|
||||
for OpenAI\-compatible providers)
|
||||
.PP
|
||||
\f[B]\-v\f[R], \f[B]\-\-verbose\f[R] : Show verbose output
|
||||
.PP
|
||||
\f[B]\-c\f[R], \f[B]\-\-cwd\f[R] \f[I]path\f[R] : Working directory
|
||||
.PP
|
||||
\f[B]\-\-config\f[R] \f[I]path\f[R] : Path to Cline configuration
|
||||
directory
|
||||
.SS update
|
||||
Check for updates and install if available.
|
||||
.PP
|
||||
\f[B]cline update\f[R] [\f[I]options\f[R]] : Check npm for newer
|
||||
versions.
|
||||
Options:
|
||||
.PP
|
||||
\f[B]\-v\f[R], \f[B]\-\-verbose\f[R] : Show verbose output
|
||||
.SS version
|
||||
Show the CLI version number.
|
||||
.PP
|
||||
\f[B]cline version\f[R]
|
||||
.SS dev
|
||||
Developer tools and utilities.
|
||||
.PP
|
||||
\f[B]cline dev log\f[R] : Open the log file for debugging.
|
||||
.SH DEFAULT COMMAND OPTIONS
|
||||
When running \f[B]cline\f[R] with just a prompt (no subcommand), these
|
||||
options are available:
|
||||
.PP
|
||||
\f[B]\-a\f[R], \f[B]\-\-act\f[R] : Run in act mode (default)
|
||||
.PP
|
||||
\f[B]\-p\f[R], \f[B]\-\-plan\f[R] : Run in plan mode
|
||||
.PP
|
||||
\f[B]\-y\f[R], \f[B]\-\-yolo\f[R] : Enable yolo mode (auto\-approve all
|
||||
actions).
|
||||
Also forces plain text output mode.
|
||||
.PP
|
||||
\f[B]\-m\f[R], \f[B]\-\-model\f[R] \f[I]model\f[R] : Model to use for
|
||||
the task
|
||||
.PP
|
||||
\f[B]\-v\f[R], \f[B]\-\-verbose\f[R] : Show verbose output
|
||||
.PP
|
||||
\f[B]\-c\f[R], \f[B]\-\-cwd\f[R] \f[I]path\f[R] : Working directory
|
||||
.PP
|
||||
\f[B]\-\-config\f[R] \f[I]path\f[R] : Configuration directory
|
||||
.PP
|
||||
\f[B]\-\-thinking\f[R] : Enable extended thinking (1024 token budget)
|
||||
.PP
|
||||
\f[B]\-\-json\f[R] : Output messages as JSON instead of styled text.
|
||||
Forces plain text mode.
|
||||
.SH JSON OUTPUT FORMAT
|
||||
When using \f[B]\-\-json\f[R], each message is output as a JSON object
|
||||
with these fields:
|
||||
.PP
|
||||
\f[B]Required fields:\f[R]
|
||||
.IP \(bu 2
|
||||
\f[B]type\f[R]: \(lqask\(rq or \(lqsay\(rq
|
||||
.IP \(bu 2
|
||||
\f[B]text\f[R]: message text
|
||||
.IP \(bu 2
|
||||
\f[B]ts\f[R]: Unix epoch timestamp in milliseconds
|
||||
.PP
|
||||
\f[B]Optional fields:\f[R]
|
||||
.IP \(bu 2
|
||||
\f[B]reasoning\f[R]: reasoning text
|
||||
.IP \(bu 2
|
||||
\f[B]say\f[R]: say subtype (when type is \(lqsay\(rq)
|
||||
.IP \(bu 2
|
||||
\f[B]ask\f[R]: ask subtype (when type is \(lqask\(rq)
|
||||
.IP \(bu 2
|
||||
\f[B]partial\f[R]: streaming flag
|
||||
.IP \(bu 2
|
||||
\f[B]images\f[R]: list of image URIs
|
||||
.IP \(bu 2
|
||||
\f[B]files\f[R]: list of file paths
|
||||
.SH EXAMPLES
|
||||
.SS Basic Usage
|
||||
.IP
|
||||
.EX
|
||||
cat requirements.txt \f[B]|\f[R] cline task send
|
||||
echo \(dqRefactor this code\(dq \f[B]|\f[R] cline \-y
|
||||
\f[I]# Launch interactive mode\f[R]
|
||||
cline
|
||||
|
||||
\f[I]# Run a task directly\f[R]
|
||||
cline \(dqCreate a hello world function in Python\(dq
|
||||
|
||||
\f[I]# Run with verbose output and extended thinking\f[R]
|
||||
cline \-v \-\-thinking \(dqAnalyze this codebase architecture\(dq
|
||||
.EE
|
||||
.SS Instance Management
|
||||
Manage multiple Cline instances:
|
||||
.SS Mode Selection
|
||||
.IP
|
||||
.EX
|
||||
\f[I]# Start a new instance and make it default\f[R]
|
||||
cline instance new \-\-default
|
||||
\f[I]# Run in plan mode (gather info before acting)\f[R]
|
||||
cline \-p \(dqDesign a REST API for user management\(dq
|
||||
|
||||
\f[I]# List all running instances\f[R]
|
||||
cline instance list
|
||||
\f[I]# Run in act mode with auto\-approval (yolo)\f[R]
|
||||
cline \-y \(dqFix the typo in README.md\(dq
|
||||
.EE
|
||||
.SS Using Specific Models
|
||||
.IP
|
||||
.EX
|
||||
\f[I]# Use a specific model\f[R]
|
||||
cline \-m claude\-sonnet\-4\-5\-20250929 \(dqRefactor this function\(dq
|
||||
|
||||
\f[I]# Kill a specific instance\f[R]
|
||||
cline instance kill localhost:50052
|
||||
\f[I]# Quick auth setup with model\f[R]
|
||||
cline auth \-p anthropic \-k sk\-ant\-xxxxx \-m claude\-sonnet\-4\-5\-20250929
|
||||
.EE
|
||||
.SS Including Images
|
||||
.IP
|
||||
.EX
|
||||
\f[I]# Include images with explicit flag\f[R]
|
||||
cline task \-i screenshot.png diagram.jpg \(dqFix the UI based on these images\(dq
|
||||
|
||||
\f[I]# Kill all CLI instances\f[R]
|
||||
cline instance kill \-\-all\-cli
|
||||
\f[I]# Or use inline image references in the prompt\f[R]
|
||||
cline \(dqFix the layout shown in \(at./screenshot.png\(dq
|
||||
.EE
|
||||
.SS Piped Input
|
||||
.IP
|
||||
.EX
|
||||
\f[I]# Pipe file contents to Cline\f[R]
|
||||
cat README.md \f[B]|\f[R] cline \(dqSummarize this document\(dq
|
||||
|
||||
\f[I]# Pipe with additional prompt\f[R]
|
||||
echo \(dqfunction add(a, b) { return a + b }\(dq \f[B]|\f[R] cline \(dqAdd TypeScript types to this\(dq
|
||||
|
||||
\f[I]# Combine piped input with a prompt\f[R]
|
||||
git diff \f[B]|\f[R] cline \(dqReview these changes and suggest improvements\(dq
|
||||
.EE
|
||||
.SS Scripting and Automation
|
||||
.IP
|
||||
.EX
|
||||
\f[I]# JSON output for parsing\f[R]
|
||||
cline \-\-json \(dqWhat files are in this directory?\(dq \f[B]|\f[R] jq \(aq.text\(aq
|
||||
|
||||
\f[I]# Yolo mode for automated workflows (auto\-approves all actions), forces plain text output\f[R]
|
||||
cline \-y \(dqRun the test suite and fix any failures\(dq
|
||||
.EE
|
||||
.SS Task History
|
||||
Work with task history:
|
||||
.IP
|
||||
.EX
|
||||
\f[I]# List previous tasks\f[R]
|
||||
cline task list
|
||||
\f[I]# List recent tasks\f[R]
|
||||
cline history
|
||||
|
||||
\f[I]# Resume a previous task\f[R]
|
||||
cline task open 1760501486669
|
||||
|
||||
\f[I]# View conversation history\f[R]
|
||||
cline task view
|
||||
|
||||
\f[I]# Start interactive chat with this task\f[R]
|
||||
cline task chat
|
||||
\f[I]# Show more tasks with pagination\f[R]
|
||||
cline history \-n 20 \-p 2
|
||||
.EE
|
||||
.SH ARCHITECTURE
|
||||
Cline operates on a three\-layer architecture:
|
||||
.TP
|
||||
\f[B]Presentation Layer\f[R]
|
||||
User interfaces (CLI, VSCode, JetBrains) that connect to Cline Core via
|
||||
gRPC
|
||||
.TP
|
||||
\f[B]Cline Core\f[R]
|
||||
The autonomous agent service handling task management, AI model
|
||||
integration, state management, tool orchestration, and real\-time
|
||||
streaming updates
|
||||
.TP
|
||||
\f[B]Host Provider Layer\f[R]
|
||||
Environment\-specific integrations (VSCode APIs, JetBrains APIs, shell
|
||||
APIs) that Cline Core uses to interact with the host system
|
||||
.SS Authentication
|
||||
.IP
|
||||
.EX
|
||||
\f[I]# Interactive authentication wizard\f[R]
|
||||
cline auth
|
||||
|
||||
\f[I]# Quick setup for Anthropic\f[R]
|
||||
cline auth \-p anthropic \-k sk\-ant\-api\-xxxxx
|
||||
|
||||
\f[I]# Quick setup for OpenAI\f[R]
|
||||
cline auth \-p openai\-native \-k sk\-xxxxx \-m gpt\-4o
|
||||
|
||||
\f[I]# OpenAI\-compatible provider with custom base URL\f[R]
|
||||
cline auth \-p openai \-k your\-api\-key \-b https://api.example.com/v1
|
||||
.EE
|
||||
.SH ENVIRONMENT
|
||||
\f[B]CLINE_DIR\f[R] : Override the default configuration directory.
|
||||
When set, Cline stores all data in this directory instead of
|
||||
\f[CR]\(ti/.cline/data/\f[R].
|
||||
.PP
|
||||
\f[B]CLINE_COMMAND_PERMISSIONS\f[R] : JSON configuration for restricting
|
||||
which shell commands Cline can execute.
|
||||
When set, commands are validated against allow/deny patternks before
|
||||
execution.
|
||||
When not set, all commands are allowed.
|
||||
.PP
|
||||
Format:
|
||||
\f[CR]{\(dqallow\(dq: [\(dqpattern1\(dq, \(dqpattern2\(dq], \(dqdeny\(dq: [\(dqpattern3\(dq], \(dqallowRedirects\(dq: true}\f[R]
|
||||
.PP
|
||||
\f[B]Fields:\f[R]
|
||||
.IP \(bu 2
|
||||
\f[B]allow\f[R] (array of strings): Glob patterns for allowed commands.
|
||||
If specified, only matching commands are permitted.
|
||||
Uses \f[CR]*\f[R] to match any characters and \f[CR]?\f[R] to match a
|
||||
single character.
|
||||
Setting allow on anything will deny all others.
|
||||
.IP \(bu 2
|
||||
\f[B]deny\f[R] (array of strings): Glob patterns for denied commands.
|
||||
Deny rules take precedence over allow rules.
|
||||
.IP \(bu 2
|
||||
\f[B]allowRedirects\f[R] (boolean): Whether to allow shell redirects
|
||||
(\f[CR]>\f[R], \f[CR]>>\f[R], \f[CR]<\f[R], etc.).
|
||||
Defaults to false.
|
||||
.PP
|
||||
\f[B]Rule evaluation:\f[R]
|
||||
.IP "1." 3
|
||||
Check for dangerous characters (backticks outside single quotes,
|
||||
unquoted newlines)
|
||||
.IP "2." 3
|
||||
Parse command into segments split by operators (\f[CR]&&\f[R],
|
||||
\f[CR]||\f[R], \f[CR]|\f[R], \f[CR];\f[R])
|
||||
.IP "3." 3
|
||||
If redirects detected and \f[CR]allowRedirects\f[R] is not true, command
|
||||
is denied
|
||||
.IP "4." 3
|
||||
Each segment is validated against deny rules first, then allow rules
|
||||
.IP "5." 3
|
||||
Subshell contents (\f[CR]$(...)\f[R] and \f[CR](...)\f[R]) are
|
||||
recursively validated
|
||||
.IP "6." 3
|
||||
All segments must pass for the command to be allowed
|
||||
.PP
|
||||
\f[B]Examples:\f[R]
|
||||
.IP
|
||||
.EX
|
||||
\f[I]# Allow only npm and git commands.\f[R]
|
||||
export CLINE_COMMAND_PERMISSIONS=\(aq{\(dqallow\(dq: [\(dqnpm *\(dq, \(dqgit *\(dq]}\(aq
|
||||
|
||||
\f[I]# Allow development commands but deny dangerous ones. Deny not strictly required here since allow is set.\f[R]
|
||||
export CLINE_COMMAND_PERMISSIONS=\(aq{\(dqallow\(dq: [\(dqnpm *\(dq, \(dqgit *\(dq, \(dqnode *\(dq], \(dqdeny\(dq: [\(dqrm \-rf *\(dq, \(dqsudo *\(dq]}\(aq
|
||||
|
||||
\f[I]# Allow file operations with redirects\f[R]
|
||||
export CLINE_COMMAND_PERMISSIONS=\(aq{\(dqallow\(dq: [\(dqcat *\(dq, \(dqecho *\(dq], \(dqallowRedirects\(dq: true}\(aq
|
||||
.EE
|
||||
.SH FILES
|
||||
\f[B]\(ti/.cline/data/\f[R] : Default configuration directory
|
||||
containing:
|
||||
.PP
|
||||
\f[B]globalState.json\f[R] : Global settings and state
|
||||
.PP
|
||||
\f[B]secrets.json\f[R] : API keys and secrets (stored securely)
|
||||
.PP
|
||||
\f[B]workspace/\f[R] : Workspace\-specific state
|
||||
.PP
|
||||
\f[B]tasks/\f[R] : Task history and conversation data
|
||||
.PP
|
||||
\f[B]\(ti/.cline/log/\f[R] : Log files for debugging.
|
||||
View with \f[CR]cline dev log\f[R].
|
||||
.SH BUGS
|
||||
Report bugs at: \c
|
||||
.UR https://github.com/cline/cline/issues
|
||||
@@ -325,7 +374,11 @@ For real\-time help, join the Discord community at: \c
|
||||
Full documentation: \c
|
||||
.UR https://docs.cline.bot
|
||||
.UE \c
|
||||
.PP
|
||||
VS Code extension: \c
|
||||
.UR https://marketplace.visualstudio.com/items?itemName=saoudrizwan.claude-dev
|
||||
.UE \c
|
||||
.SH AUTHORS
|
||||
Cline is developed by the Cline Bot Inc.\ and the open source community.
|
||||
Cline is developed by Cline Bot Inc.\ and the open source community.
|
||||
.SH COPYRIGHT
|
||||
Copyright © 2025 Cline Bot Inc.\ Licensed under the Apache License 2.0.
|
||||
|
||||
+204
-196
@@ -2,316 +2,322 @@
|
||||
title: CLINE
|
||||
section: 1
|
||||
header: User Commands
|
||||
footer: Cline CLI 1.0
|
||||
date: January 2025
|
||||
footer: Cline CLI 2.0
|
||||
date: January 2026
|
||||
---
|
||||
|
||||
# NAME
|
||||
|
||||
cline - orchestrate and interact with Cline AI coding agents
|
||||
cline - AI coding assistant in your terminal
|
||||
|
||||
# SYNOPSIS
|
||||
|
||||
**cline** [*prompt*] [*options*]
|
||||
|
||||
**cline** *command* [*subcommand*] [*options*] [*arguments*]
|
||||
**cline** *command* [*options*] [*arguments*]
|
||||
|
||||
# DESCRIPTION
|
||||
|
||||
Try: cat README.md | cline "Summarize this for me:"
|
||||
**cline** is a command-line interface for the Cline AI coding assistant. It provides the same powerful AI capabilities as the VS Code extension, directly in your terminal.
|
||||
|
||||
**cline** is a command-line interface for orchestrating multiple Cline AI coding agents. Cline is an autonomous AI agent who can read, write, and execute code across your projects. He operates through a client-server architecture where **Cline Core** runs as a standalone service, and the CLI acts as a scriptable interface for managing tasks, instances, and agent interactions.
|
||||
Cline is an autonomous AI agent that can read, write, and execute code across your projects. He can create and edit files, run terminal commands, use a headless browser, and more—all while asking for your approval before taking actions.
|
||||
|
||||
The CLI is designed for both interactive use and automation, making it ideal for CI/CD pipelines, parallel task execution, and terminal-based workflows. Multiple frontends (CLI, VSCode, JetBrains) can attach to the same Cline Core instance, enabling seamless task handoff between environments.
|
||||
The CLI supports both interactive mode (with a rich terminal UI) and plain text mode (for piped input and scripted workflows).
|
||||
|
||||
# MODES OF OPERATION
|
||||
|
||||
**Instant Task Mode**
|
||||
**Interactive Mode** : When you run **cline** without arguments, it launches an interactive welcome prompt with a rich terminal UI. You can type your task, view conversation history, and interact with Cline in real-time.
|
||||
|
||||
: The simplest invocation: **cline "prompt here"** immediately spawns an instance, creates a task, and enters chat mode. This is equivalent to running **cline instance new && cline task new && cline task chat** in sequence.
|
||||
**Task Mode** : Run **cline "prompt"** or **cline task "prompt"** to immediately start a task. If stdin is a TTY, you'll see the interactive UI. If stdin is piped or output is redirected, the CLI automatically switches to plain text mode.
|
||||
|
||||
**Subcommand Mode**
|
||||
|
||||
: Advanced usage with explicit control: **cline \<command\> [subcommand] [options]** provides fine-grained control over instances, tasks, authentication, and configuration.
|
||||
**Plain Text Mode** : Activated automatically when stdin is piped, output is redirected, or **\--json**/**\--yolo** flags are used. Outputs clean text without the Ink UI, suitable for scripting and CI/CD pipelines.
|
||||
|
||||
# AGENT BEHAVIOR
|
||||
|
||||
Cline operates in two primary modes:
|
||||
|
||||
**ACT MODE**
|
||||
**ACT MODE** : Cline actively uses tools to accomplish tasks. He can read files, write code, execute commands, use a headless browser, and more. This is the default mode for task execution.
|
||||
|
||||
: Cline actively uses tools to accomplish tasks. He can read files, write code, execute commands, use a headless browser, and more. This is the default mode for task execution.
|
||||
|
||||
**PLAN MODE**
|
||||
|
||||
: Cline gathers information and creates a detailed plan before implementation. He explores the codebase, asks clarifying questions, and presents a strategy for user approval before switching to ACT MODE.
|
||||
|
||||
# INSTANT TASK OPTIONS
|
||||
|
||||
When using the instant task syntax **cline "prompt"** the following options are available:
|
||||
|
||||
**-o**, **\--oneshot**
|
||||
|
||||
: Full autonomous mode. Cline completes the task and stops following after completion. Example: cline -o "what's 6 + 8?"
|
||||
|
||||
**-s**, **\--setting** *setting* *value*
|
||||
|
||||
: Override a setting for this task
|
||||
|
||||
**-y**, **\--no-interactive**, **\--yolo**
|
||||
|
||||
: Enable fully autonomous mode. Disables all interactivity:
|
||||
- ask_followup_question tool is disabled
|
||||
- attempt_completion happens automatically
|
||||
- execute_command runs in non-blocking mode with timeout
|
||||
- PLAN MODE automatically switches to ACT MODE
|
||||
|
||||
**-m**, **\--mode** *mode*
|
||||
|
||||
: Starting mode. Options: **act** (default), **plan**
|
||||
|
||||
# GLOBAL OPTIONS
|
||||
|
||||
These options apply to all subcommands:
|
||||
|
||||
**-F**, **\--output-format** *format*
|
||||
|
||||
: Output format. Options: **rich** (default), **json**, **plain**
|
||||
|
||||
**-h**, **\--help**
|
||||
|
||||
: Display help information for the command.
|
||||
|
||||
**-v**, **\--verbose**
|
||||
|
||||
: Enable verbose output for debugging.
|
||||
**PLAN MODE** : Cline gathers information and creates a detailed plan before implementation. He explores the codebase, asks clarifying questions, and presents a strategy for user approval before switching to ACT MODE.
|
||||
|
||||
# COMMANDS
|
||||
|
||||
## Authentication
|
||||
## task (alias: t)
|
||||
|
||||
**cline auth** [*provider*] [*key*]
|
||||
Run a new task with a prompt.
|
||||
|
||||
**cline a** [*provider*] [*key*]
|
||||
**cline task** *prompt* [*options*]
|
||||
|
||||
: Configure authentication for AI model providers. Launches an interactive wizard if no arguments provided. If provider is specified without a key, prompts for the key or launches the appropriate OAuth flow.
|
||||
**cline t** *prompt* [*options*] : Create and run a new task. Options:
|
||||
|
||||
## Instance Management
|
||||
**-a**, **\--act** : Run in act mode (default)
|
||||
|
||||
Cline Core instances are independent agent processes that can run in the background. Multiple instances can run simultaneously, enabling parallel task execution.
|
||||
**-p**, **\--plan** : Run in plan mode
|
||||
|
||||
**cline instance**
|
||||
**-y**, **\--yolo** : Enable yolo/yes mode (auto-approve all actions, output in plain mode, exit process automatically when task complete)
|
||||
|
||||
**cline i**
|
||||
**-m**, **\--model** *model* : Model to use for the task
|
||||
|
||||
: Display instance management help.
|
||||
**-i**, **\--images** *paths...* : Image file paths to include with the task
|
||||
|
||||
**cline instance new** [**-d**|**\--default**]
|
||||
**-v**, **\--verbose** : Show verbose output including reasoning
|
||||
|
||||
**cline i n** [**-d**|**\--default**]
|
||||
**-c**, **\--cwd** *path* : Working directory for the task
|
||||
|
||||
: Spawn a new Cline Core instance. Use **\--default** to set it as the default instance for subsequent commands.
|
||||
**\--config** *path* : Path to Cline configuration directory
|
||||
|
||||
**cline instance list**
|
||||
**\--thinking** : Enable extended thinking (1024 token budget)
|
||||
|
||||
**cline i l**
|
||||
**\--json** : Output messages as JSON instead of styled text
|
||||
|
||||
: List all running Cline Core instances with their addresses and status.
|
||||
## history (alias: h)
|
||||
|
||||
**cline instance default** *address*
|
||||
List task history with pagination.
|
||||
|
||||
**cline i d** *address*
|
||||
**cline history** [*options*]
|
||||
|
||||
: Set the default instance to avoid specifying **\--address** in task commands.
|
||||
**cline h** [*options*] : Display previous tasks. Options:
|
||||
|
||||
**cline instance kill** *address* [**-a**|**\--all**]
|
||||
**-n**, **\--limit** *number* : Number of tasks to show (default: 10)
|
||||
|
||||
**cline i k** *address* [**-a**|**\--all**]
|
||||
**-p**, **\--page** *number* : Page number, 1-based (default: 1)
|
||||
|
||||
: Terminate a Cline Core instance. Use **\--all** to kill all running instances.
|
||||
**\--config** *path* : Path to Cline configuration directory
|
||||
|
||||
## Task Management
|
||||
## config
|
||||
|
||||
Tasks represent individual work items that Cline executes. Tasks maintain conversation history, checkpoints, and settings.
|
||||
Show current configuration.
|
||||
|
||||
**cline task** [**-a**|**\--address** *ADDR*]
|
||||
**cline config** [*options*] : Display global and workspace state. Options:
|
||||
|
||||
**cline t** [**-a**|**\--address** *ADDR*]
|
||||
**\--config** *path* : Path to Cline configuration directory
|
||||
|
||||
: Display task management help. The **\--address** flag specifies which Cline Core instance to use (e.g., localhost:50052).
|
||||
## auth
|
||||
|
||||
**cline task new** *prompt* [*options*]
|
||||
Authenticate a provider and configure the model.
|
||||
|
||||
**cline t n** *prompt* [*options*]
|
||||
**cline auth** [*options*] : Launch interactive authentication wizard, or use quick setup flags. Options:
|
||||
|
||||
: Create a new task in the default or specified instance. Options:
|
||||
**-p**, **\--provider** *id* : Provider ID for quick setup (e.g., openai-native, anthropic, openrouter)
|
||||
|
||||
**-s**, **\--setting** *setting* *value*
|
||||
: Set task-specific settings
|
||||
**-k**, **\--apikey** *key* : API key for the provider
|
||||
|
||||
**-y**, **\--no-interactive**, **\--yolo**
|
||||
: Enable autonomous mode
|
||||
**-m**, **\--modelid** *id* : Model ID to configure (e.g., gpt-4o, claude-sonnet-4-5-20250929)
|
||||
|
||||
**-m**, **\--mode** *mode*
|
||||
: Starting mode (act or plan)
|
||||
**-b**, **\--baseurl** *url* : Base URL (optional, for OpenAI-compatible providers)
|
||||
|
||||
**cline task open** *task-id* [*options*]
|
||||
**-v**, **\--verbose** : Show verbose output
|
||||
|
||||
**cline t o** *task-id* [*options*]
|
||||
**-c**, **\--cwd** *path* : Working directory
|
||||
|
||||
: Resume a previous task from history. Accepts the same options as **task new**.
|
||||
**\--config** *path* : Path to Cline configuration directory
|
||||
|
||||
**cline task list**
|
||||
## update
|
||||
|
||||
**cline t l**
|
||||
Check for updates and install if available.
|
||||
|
||||
: List all tasks in history with their id and snippet
|
||||
**cline update** [*options*] : Check bun for newer versions. Options:
|
||||
|
||||
**cline task chat**
|
||||
**-v**, **\--verbose** : Show verbose output
|
||||
|
||||
**cline t c**
|
||||
## version
|
||||
|
||||
: Enter interactive chat mode for the current task. Allows back-and-forth conversation with Cline.
|
||||
Show the CLI version number.
|
||||
|
||||
**cline task send** [*message*] [*options*]
|
||||
**cline version**
|
||||
|
||||
**cline t s** [*message*] [*options*]
|
||||
## dev
|
||||
|
||||
: Send a message to Cline. If no message is provided, reads from stdin. Options:
|
||||
Developer tools and utilities.
|
||||
|
||||
**-a**, **\--approve**
|
||||
: Approve Cline's proposed action
|
||||
**cline dev log** : Open the log file for debugging.
|
||||
|
||||
**-d**, **\--deny**
|
||||
: Deny Cline's proposed action
|
||||
# DEFAULT COMMAND OPTIONS
|
||||
|
||||
**-f**, **\--file** *FILE*
|
||||
: Attach a file to the message
|
||||
When running **cline** with just a prompt (no subcommand), these options are available:
|
||||
|
||||
**-y**, **\--no-interactive**, **\--yolo**
|
||||
: Enable autonomous mode
|
||||
**-a**, **\--act** : Run in act mode (default)
|
||||
|
||||
**-m**, **\--mode** *mode*
|
||||
: Switch mode (act or plan)
|
||||
**-p**, **\--plan** : Run in plan mode
|
||||
|
||||
**cline task view** [**-f**|**\--follow**] [**-c**|**\--follow-complete**]
|
||||
**-y**, **\--yolo** : Enable yolo mode (auto-approve all actions). Also forces plain text output mode.
|
||||
|
||||
**cline t v** [**-f**|**\--follow**] [**-c**|**\--follow-complete**]
|
||||
**-m**, **\--model** *model* : Model to use for the task
|
||||
|
||||
: Display the current conversation. Use **\--follow** to stream updates in real-time, or **\--follow-complete** to follow until task completion.
|
||||
**-v**, **\--verbose** : Show verbose output
|
||||
|
||||
**cline task restore** *checkpoint*
|
||||
**-c**, **\--cwd** *path* : Working directory
|
||||
|
||||
**cline t r** *checkpoint*
|
||||
**\--config** *path* : Configuration directory
|
||||
|
||||
: Restore the task to a previous checkpoint state.
|
||||
**\--thinking** : Enable extended thinking (1024 token budget)
|
||||
|
||||
**cline task pause**
|
||||
**\--json** : Output messages as JSON instead of styled text. Forces plain text mode.
|
||||
|
||||
**cline t p**
|
||||
# JSON OUTPUT FORMAT
|
||||
|
||||
: Pause task execution.
|
||||
When using **\--json**, each message is output as a JSON object with these fields:
|
||||
|
||||
## Configuration
|
||||
**Required fields:**
|
||||
|
||||
Configuration can be set globally. Override these global settings for a task using the **\--setting** flag
|
||||
- **type**: "ask" or "say"
|
||||
- **text**: message text
|
||||
- **ts**: Unix epoch timestamp in milliseconds
|
||||
|
||||
**cline config**
|
||||
**Optional fields:**
|
||||
|
||||
**cline c**
|
||||
- **reasoning**: reasoning text
|
||||
- **say**: say subtype (when type is "say")
|
||||
- **ask**: ask subtype (when type is "ask")
|
||||
- **partial**: streaming flag
|
||||
- **images**: list of image URIs
|
||||
- **files**: list of file paths
|
||||
|
||||
**cline config set** *key* *value*
|
||||
# EXAMPLES
|
||||
|
||||
**cline c s** *key* *value*
|
||||
|
||||
: Set a configuration variable.
|
||||
|
||||
**cline config get** *key*
|
||||
|
||||
**cline c g** *key*
|
||||
|
||||
: Read a configuration variable.
|
||||
|
||||
**cline config list**
|
||||
|
||||
**cline c l**
|
||||
|
||||
: List all configuration variables and their values.
|
||||
|
||||
# TASK SETTINGS
|
||||
|
||||
Task settings are persisted in the *~/.cline/x/tasks* directory. When resuming a task with **cline task open**, task settings are automatically restored.
|
||||
|
||||
Common settings include:
|
||||
|
||||
**yolo**
|
||||
|
||||
: Enable autonomous mode (true/false)
|
||||
|
||||
**mode**
|
||||
|
||||
: Starting mode (act/plan)
|
||||
|
||||
# NOTES & EXAMPLES
|
||||
|
||||
The **cline task send** and **cline task new** commands support reading from stdin, enabling powerful pipeline compositions:
|
||||
## Basic Usage
|
||||
|
||||
```bash
|
||||
cat requirements.txt | cline task send
|
||||
echo "Refactor this code" | cline -y
|
||||
# Launch interactive mode
|
||||
cline
|
||||
|
||||
# Run a task directly
|
||||
cline "Create a hello world function in Python"
|
||||
|
||||
# Run with verbose output and extended thinking
|
||||
cline -v --thinking "Analyze this codebase architecture"
|
||||
```
|
||||
|
||||
## Instance Management
|
||||
|
||||
Manage multiple Cline instances:
|
||||
## Mode Selection
|
||||
|
||||
```bash
|
||||
# Start a new instance and make it default
|
||||
cline instance new --default
|
||||
# Run in plan mode (gather info before acting)
|
||||
cline -p "Design a REST API for user management"
|
||||
|
||||
# List all running instances
|
||||
cline instance list
|
||||
# Run in act mode with auto-approval (yolo)
|
||||
cline -y "Fix the typo in README.md"
|
||||
```
|
||||
|
||||
# Kill a specific instance
|
||||
cline instance kill localhost:50052
|
||||
## Using Specific Models
|
||||
|
||||
# Kill all CLI instances
|
||||
cline instance kill --all-cli
|
||||
```bash
|
||||
# Use a specific model
|
||||
cline -m claude-sonnet-4-5-20250929 "Refactor this function"
|
||||
|
||||
# Quick auth setup with model
|
||||
cline auth -p anthropic -k sk-ant-xxxxx -m claude-sonnet-4-5-20250929
|
||||
```
|
||||
|
||||
## Including Images
|
||||
|
||||
```bash
|
||||
# Include images with explicit flag
|
||||
cline task -i screenshot.png diagram.jpg "Fix the UI based on these images"
|
||||
|
||||
# Or use inline image references in the prompt
|
||||
cline "Fix the layout shown in @./screenshot.png"
|
||||
```
|
||||
|
||||
## Piped Input
|
||||
|
||||
```bash
|
||||
# Pipe file contents to Cline
|
||||
cat README.md | cline "Summarize this document"
|
||||
|
||||
# Pipe with additional prompt
|
||||
echo "function add(a, b) { return a + b }" | cline "Add TypeScript types to this"
|
||||
|
||||
# Combine piped input with a prompt
|
||||
git diff | cline "Review these changes and suggest improvements"
|
||||
```
|
||||
|
||||
## Scripting and Automation
|
||||
|
||||
```bash
|
||||
# JSON output for parsing
|
||||
cline --json "What files are in this directory?" | jq '.text'
|
||||
|
||||
# Yolo mode for automated workflows (auto-approves all actions), forces plain text output
|
||||
cline -y "Run the test suite and fix any failures"
|
||||
```
|
||||
|
||||
## Task History
|
||||
|
||||
Work with task history:
|
||||
|
||||
```bash
|
||||
# List previous tasks
|
||||
cline task list
|
||||
# List recent tasks
|
||||
cline history
|
||||
|
||||
# Resume a previous task
|
||||
cline task open 1760501486669
|
||||
|
||||
# View conversation history
|
||||
cline task view
|
||||
|
||||
# Start interactive chat with this task
|
||||
cline task chat
|
||||
# Show more tasks with pagination
|
||||
cline history -n 20 -p 2
|
||||
```
|
||||
|
||||
# ARCHITECTURE
|
||||
## Authentication
|
||||
|
||||
Cline operates on a three-layer architecture:
|
||||
```bash
|
||||
# Interactive authentication wizard
|
||||
cline auth
|
||||
|
||||
**Presentation Layer**
|
||||
# Quick setup for Anthropic
|
||||
cline auth -p anthropic -k sk-ant-api-xxxxx
|
||||
|
||||
: User interfaces (CLI, VSCode, JetBrains) that connect to Cline Core via gRPC
|
||||
# Quick setup for OpenAI
|
||||
cline auth -p openai-native -k sk-xxxxx -m gpt-4o
|
||||
|
||||
**Cline Core**
|
||||
# OpenAI-compatible provider with custom base URL
|
||||
cline auth -p openai -k your-api-key -b https://api.example.com/v1
|
||||
```
|
||||
|
||||
: The autonomous agent service handling task management, AI model integration, state management, tool orchestration, and real-time streaming updates
|
||||
# ENVIRONMENT
|
||||
|
||||
**Host Provider Layer**
|
||||
**CLINE_DIR** : Override the default configuration directory. When set, Cline stores all data in this directory instead of `~/.cline/data/`.
|
||||
|
||||
**CLINE_COMMAND_PERMISSIONS** : JSON configuration for restricting which shell commands Cline can execute. When set, commands are validated against allow/deny patternks before execution. When not set, all commands are allowed.
|
||||
|
||||
Format: `{"allow": ["pattern1", "pattern2"], "deny": ["pattern3"], "allowRedirects": true}`
|
||||
|
||||
**Fields:**
|
||||
|
||||
- **allow** (array of strings): Glob patterns for allowed commands. If specified, only matching commands are permitted. Uses `*` to match any characters and `?` to match a single character. Setting allow on anything will deny all others.
|
||||
- **deny** (array of strings): Glob patterns for denied commands. Deny rules take precedence over allow rules.
|
||||
- **allowRedirects** (boolean): Whether to allow shell redirects (`>`, `>>`, `<`, etc.). Defaults to false.
|
||||
|
||||
**Rule evaluation:**
|
||||
|
||||
1. Check for dangerous characters (backticks outside single quotes, unquoted newlines)
|
||||
2. Parse command into segments split by operators (`&&`, `||`, `|`, `;`)
|
||||
3. If redirects detected and `allowRedirects` is not true, command is denied
|
||||
4. Each segment is validated against deny rules first, then allow rules
|
||||
5. Subshell contents (`$(...)` and `(...)`) are recursively validated
|
||||
6. All segments must pass for the command to be allowed
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Allow only bun and git commands.
|
||||
export CLINE_COMMAND_PERMISSIONS='{"allow": ["bun *", "git *"]}'
|
||||
|
||||
# Allow development commands but deny dangerous ones. Deny not strictly required here since allow is set.
|
||||
export CLINE_COMMAND_PERMISSIONS='{"allow": ["bun *", "git *", "node *"], "deny": ["rm -rf *", "sudo *"]}'
|
||||
|
||||
# Allow file operations with redirects
|
||||
export CLINE_COMMAND_PERMISSIONS='{"allow": ["cat *", "echo *"], "allowRedirects": true}'
|
||||
```
|
||||
|
||||
|
||||
# CONFIGURATION FILES
|
||||
|
||||
```
|
||||
~/.cline/
|
||||
├── data/ # Default configuration directory
|
||||
│ ├── globalState.json # Global settings and state
|
||||
│ ├── secrets.json # API keys and secrets (stored securely)
|
||||
│ ├── workspace/ # Workspace-specific state
|
||||
│ └── tasks/ # Task history and conversation data
|
||||
└── log/ # Log files for debugging
|
||||
```
|
||||
|
||||
View logs with `cline dev log`.
|
||||
|
||||
: Environment-specific integrations (VSCode APIs, JetBrains APIs, shell APIs) that Cline Core uses to interact with the host system
|
||||
|
||||
# BUGS
|
||||
|
||||
@@ -323,9 +329,11 @@ For real-time help, join the Discord community at: <https://discord.gg/cline>
|
||||
|
||||
Full documentation: <https://docs.cline.bot>
|
||||
|
||||
VS Code extension: <https://marketplace.visualstudio.com/items?itemName=saoudrizwan.claude-dev>
|
||||
|
||||
# AUTHORS
|
||||
|
||||
Cline is developed by the Cline Bot Inc. and the open source community.
|
||||
Cline is developed by Cline Bot Inc. and the open source community.
|
||||
|
||||
# COPYRIGHT
|
||||
|
||||
|
||||
+53
-30
@@ -1,27 +1,39 @@
|
||||
{
|
||||
"name": "cline",
|
||||
"version": "1.0.3",
|
||||
"version": "2.0.3",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"main": "cline-core.js",
|
||||
"main": "dist/cli.mjs",
|
||||
"bin": {
|
||||
"cline": "./bin/cline",
|
||||
"cline-host": "./bin/cline-host"
|
||||
"cline": "./dist/cli.mjs"
|
||||
},
|
||||
"man": "./man/cline.1",
|
||||
"scripts": {
|
||||
"postinstall": "node postinstall.js"
|
||||
},
|
||||
"bundleDependencies": [
|
||||
"@grpc/grpc-js",
|
||||
"@grpc/reflection",
|
||||
"better-sqlite3",
|
||||
"grpc-health-check",
|
||||
"open",
|
||||
"vscode-uri"
|
||||
"os": [
|
||||
"darwin",
|
||||
"linux",
|
||||
"win32"
|
||||
],
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64"
|
||||
],
|
||||
"man": "./man/cline.1",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"package:brew": "bunx tsx ./scripts/update-brew-formula.mts",
|
||||
"package": "bun pm pack --pack-destination ./dist",
|
||||
"build": "bun run typecheck && bunx tsx esbuild.mts",
|
||||
"build:production": "bun run typecheck && bunx tsx esbuild.mts --production",
|
||||
"watch": "bunx tsx esbuild.mts --watch",
|
||||
"dev": "IS_DEV=true && bun run link && bun run watch ; bun run unlink",
|
||||
"clean": "rimraf dist",
|
||||
"typecheck": "bunx tsc --noEmit",
|
||||
"link": "bun run build && bun link",
|
||||
"unlink": "bun unlink",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run"
|
||||
},
|
||||
"keywords": [
|
||||
"cline",
|
||||
"claude",
|
||||
@@ -49,20 +61,31 @@
|
||||
"bugs": {
|
||||
"url": "https://github.com/cline/cline/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"@grpc/grpc-js": "^1.13.3",
|
||||
"@grpc/reflection": "^1.0.4",
|
||||
"better-sqlite3": "^12.2.0",
|
||||
"grpc-health-check": "^2.0.2",
|
||||
"open": "^10.1.2",
|
||||
"vscode-uri": "^3.1.0"
|
||||
"devDependencies": {
|
||||
"@types/node": "20.x",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"@types/react": "^19.2.9",
|
||||
"dotenv": "^16.4.5",
|
||||
"esbuild": "^0.25.0",
|
||||
"ink-testing-library": "^4.0.0",
|
||||
"rimraf": "^6.0.1",
|
||||
"typescript": "^5.4.5",
|
||||
"vitest": "^4.0.17"
|
||||
},
|
||||
"os": [
|
||||
"darwin",
|
||||
"linux"
|
||||
],
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.13.1",
|
||||
"@vscode/ripgrep": "^1.15.9",
|
||||
"aws4fetch": "^1.0.20",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.1.0",
|
||||
"ink": "npm:@jrichman/ink@6.4.7",
|
||||
"ink-picture": "^1.3.3",
|
||||
"ink-spinner": "^5.0.0",
|
||||
"ora": "^8.0.1",
|
||||
"nanoid": "^5.1.6",
|
||||
"pino": "^10.0.0",
|
||||
"pino-roll": "^4.0.0",
|
||||
"prompts": "^2.4.2",
|
||||
"react": "^19.2.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"github.com/cline/cli/pkg/cli/auth"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewAuthCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "auth",
|
||||
Short: "Authenticate a provider and configure what model is used",
|
||||
Long: `Authenticate a provider and configure what model is used
|
||||
|
||||
Interactive Mode:
|
||||
Run without flags to open an interactive menu where you can:
|
||||
- Sign in to your Cline account
|
||||
- Configure other LLM providers (Anthropic, OpenAI, etc.)
|
||||
- Select and switch between AI models
|
||||
- Manage provider settings
|
||||
|
||||
Quick Setup Mode:
|
||||
Use flags to quickly configure a BYO provider non-interactively:
|
||||
|
||||
Examples:
|
||||
cline auth --provider openai-native --apikey sk-xxx --modelid gpt-5
|
||||
cline auth -p anthropic -k sk-ant-xxx -m claude-sonnet-4-5-20250929
|
||||
cline auth -p openai-compatible -k xxx -m gpt-4 -b https://api.example.com/v1
|
||||
|
||||
Supported providers: openai-native, openai, anthropic, gemini, openrouter, xai, cerebras, ollama
|
||||
Note: Bedrock provider requires interactive setup due to complex auth fields`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return auth.RunAuthFlow(cmd.Context(), args)
|
||||
},
|
||||
}
|
||||
|
||||
// Add flags for quick setup mode
|
||||
cmd.Flags().StringVarP(&auth.QuickProvider, "provider", "p", "", "Provider ID for quick setup (e.g., openai-native, anthropic)")
|
||||
cmd.Flags().StringVarP(&auth.QuickAPIKey, "apikey", "k", "", "API key for the provider")
|
||||
cmd.Flags().StringVarP(&auth.QuickModelID, "modelid", "m", "", "Model ID to configure (e.g., gpt-4o, claude-sonnet-4-5-20250929)")
|
||||
cmd.Flags().StringVarP(&auth.QuickBaseURL, "baseurl", "b", "", "Base URL (optional, only for openai provider)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -1,285 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/task"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
var isSessionAuthenticated bool
|
||||
|
||||
// Cline provider specific code
|
||||
|
||||
func HandleClineAuth(ctx context.Context) error {
|
||||
verboseLog("Authenticating with Cline...")
|
||||
|
||||
// Check if already authenticated
|
||||
if IsAuthenticated(ctx) {
|
||||
return signOutDialog(ctx)
|
||||
}
|
||||
|
||||
// Perform sign in
|
||||
if err := signIn(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
|
||||
verboseLog("✓ You are signed in!")
|
||||
|
||||
|
||||
// Configure default Cline model after successful authentication
|
||||
if err := configureDefaultClineModel(ctx); err != nil {
|
||||
fmt.Printf("Warning: Could not configure default Cline model: %v\n", err)
|
||||
fmt.Println("You can configure a model later with 'cline auth' and selecting 'Change Cline model'")
|
||||
}
|
||||
|
||||
// Return to main auth menu after successful authentication
|
||||
return HandleAuthMenuNoArgs(ctx)
|
||||
}
|
||||
|
||||
func signOut(ctx context.Context) error {
|
||||
client, err := global.GetDefaultClient(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err = client.Account.AccountLogoutClicked(ctx, &cline.EmptyRequest{}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
isSessionAuthenticated = false
|
||||
fmt.Println("You have been signed out of Cline.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func signOutDialog(ctx context.Context) error {
|
||||
var confirm bool
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewConfirm().
|
||||
Title("You are already signed in to Cline.").
|
||||
Description("Would you like to sign out?").
|
||||
Value(&confirm),
|
||||
),
|
||||
)
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if confirm {
|
||||
if err := signOut(ctx); err != nil {
|
||||
fmt.Printf("Failed to sign out: %v\n", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
return HandleAuthMenuNoArgs(ctx)
|
||||
}
|
||||
|
||||
func signIn(ctx context.Context) error {
|
||||
if IsAuthenticated(ctx) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Subscribe to auth updates before initiating login
|
||||
verboseLog("Subscribing to auth status updates...")
|
||||
listener, err := NewAuthStatusListener(ctx)
|
||||
if err != nil {
|
||||
verboseLog("Failed to subscribe to auth updates: %v", err)
|
||||
return fmt.Errorf("failed to subscribe to auth updates: %w", err)
|
||||
}
|
||||
defer listener.Stop()
|
||||
|
||||
if err := listener.Start(); err != nil {
|
||||
verboseLog("Failed to start auth listener: %v", err)
|
||||
return fmt.Errorf("failed to start auth listener: %w", err)
|
||||
}
|
||||
|
||||
// Initiate login (opens browser with callback URL from cline-core's AuthHandler)
|
||||
verboseLog("Initiating login...")
|
||||
client, err := global.GetDefaultClient(ctx)
|
||||
if err != nil {
|
||||
verboseLog("Failed to obtain client: %v", err)
|
||||
return fmt.Errorf("failed to obtain client: %w", err)
|
||||
}
|
||||
|
||||
response, err := client.Account.AccountLoginClicked(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
verboseLog("Failed to initiate login: %v", err)
|
||||
return fmt.Errorf("failed to initiate login: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("\n Opening browser for authentication...")
|
||||
if response != nil && response.Value != "" {
|
||||
fmt.Printf(" If the browser doesn't open automatically, visit this URL:\n %s\n\n", response.Value)
|
||||
}
|
||||
fmt.Println(" Waiting for you to complete authentication in your browser...")
|
||||
fmt.Println(" (This may take a few moments. Timeout: 5 minutes)")
|
||||
|
||||
// Wait for auth status update confirming success
|
||||
verboseLog("Waiting for authentication to complete...")
|
||||
if err := listener.WaitForAuthentication(5 * time.Minute); err != nil {
|
||||
verboseLog("Authentication failed or timed out: %v", err)
|
||||
fmt.Println("\n Authentication failed or timed out.")
|
||||
fmt.Println(" Please try again with 'cline auth'")
|
||||
return err
|
||||
}
|
||||
|
||||
// Only NOW set the session flag after confirmed authentication
|
||||
isSessionAuthenticated = true
|
||||
verboseLog("Login successful")
|
||||
return nil
|
||||
}
|
||||
|
||||
func IsAuthenticated(ctx context.Context) bool {
|
||||
if isSessionAuthenticated {
|
||||
verboseLog("Session is already authenticated")
|
||||
return true
|
||||
}
|
||||
|
||||
verboseLog("Verifying authentication with server...")
|
||||
client, err := global.GetDefaultClient(ctx)
|
||||
if err != nil {
|
||||
verboseLog("Failed to get client for auth check: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
_, err = client.Account.GetUserCredits(ctx, &cline.EmptyRequest{})
|
||||
if err == nil {
|
||||
// Update session variable for future fast-path checks
|
||||
verboseLog("Server verification successful, updating session flag")
|
||||
isSessionAuthenticated = true
|
||||
return true
|
||||
}
|
||||
|
||||
verboseLog("Server verification failed: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
// HandleChangeClineModel allows Cline-authenticated users to change their Cline model selection. Hidden when not authenticated.
|
||||
func HandleChangeClineModel(ctx context.Context) error {
|
||||
// Ensure user is authenticated
|
||||
if !IsAuthenticated(ctx) {
|
||||
return fmt.Errorf("you must be authenticated with Cline to change models. Run 'cline auth' to sign in")
|
||||
}
|
||||
|
||||
// Get task manager
|
||||
manager, err := createTaskManager(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create task manager: %w", err)
|
||||
}
|
||||
|
||||
// Launch Cline model selection
|
||||
return SelectClineModel(ctx, manager)
|
||||
}
|
||||
|
||||
// configureDefaultClineModel configures the default Cline model after authentication
|
||||
func configureDefaultClineModel(ctx context.Context) error {
|
||||
verboseLog("Configuring default Cline model...")
|
||||
|
||||
// Create task manager
|
||||
manager, err := task.NewManagerForDefault(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create task manager: %w", err)
|
||||
}
|
||||
|
||||
// Set default Cline model
|
||||
return SetDefaultClineModel(ctx, manager)
|
||||
}
|
||||
|
||||
// HandleSelectOrganization allows Cline-authenticated users to select which organization to use
|
||||
func HandleSelectOrganization(ctx context.Context) error {
|
||||
// Ensure user is authenticated
|
||||
if !IsAuthenticated(ctx) {
|
||||
return fmt.Errorf("you must be authenticated with Cline to select an organization. Run 'cline auth' to sign in")
|
||||
}
|
||||
|
||||
// Get client
|
||||
client, err := global.GetDefaultClient(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get client: %w", err)
|
||||
}
|
||||
|
||||
// Fetch user organizations
|
||||
orgsResponse, err := client.Account.GetUserOrganizations(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch organizations: %w", err)
|
||||
}
|
||||
|
||||
organizations := orgsResponse.GetOrganizations()
|
||||
if len(organizations) == 0 {
|
||||
fmt.Println("You don't have any organizations yet.")
|
||||
fmt.Println("Visit https://app.cline.bot/dashboard to create an organization.")
|
||||
return HandleAuthMenuNoArgs(ctx)
|
||||
}
|
||||
|
||||
// Build options list: Personal + Organizations
|
||||
var options []huh.Option[string]
|
||||
options = append(options, huh.NewOption("Personal", "personal"))
|
||||
|
||||
for _, org := range organizations {
|
||||
displayName := org.Name
|
||||
// Show active indicator
|
||||
if org.Active {
|
||||
displayName = fmt.Sprintf("%s (active)", displayName)
|
||||
}
|
||||
options = append(options, huh.NewOption(displayName, org.OrganizationId))
|
||||
}
|
||||
|
||||
options = append(options, huh.NewOption("(Cancel)", "cancel"))
|
||||
|
||||
// Show selection menu
|
||||
var selected string
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("Select which account to use").
|
||||
Options(options...).
|
||||
Value(&selected),
|
||||
),
|
||||
)
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return fmt.Errorf("failed to select organization: %w", err)
|
||||
}
|
||||
|
||||
if selected == "cancel" {
|
||||
return HandleAuthMenuNoArgs(ctx)
|
||||
}
|
||||
|
||||
// Set the organization
|
||||
var orgId *string
|
||||
if selected != "personal" {
|
||||
orgId = &selected
|
||||
}
|
||||
|
||||
req := &cline.UserOrganizationUpdateRequest{
|
||||
OrganizationId: orgId,
|
||||
}
|
||||
|
||||
if _, err := client.Account.SetUserOrganization(ctx, req); err != nil {
|
||||
return fmt.Errorf("failed to set organization: %w", err)
|
||||
}
|
||||
|
||||
if selected == "personal" {
|
||||
fmt.Println("✓ Switched to personal account")
|
||||
} else {
|
||||
// Find the org name to display
|
||||
var orgName string
|
||||
for _, org := range organizations {
|
||||
if org.OrganizationId == selected {
|
||||
orgName = org.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
fmt.Printf("✓ Switched to organization: %s\n", orgName)
|
||||
}
|
||||
|
||||
return HandleAuthMenuNoArgs(ctx)
|
||||
}
|
||||
@@ -1,323 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/cline/cli/pkg/cli/display"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/task"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
// contextKey is a distinct type for context keys to avoid collisions
|
||||
type contextKey string
|
||||
|
||||
const authInstanceAddressKey contextKey = "authInstanceAddress"
|
||||
|
||||
// AuthAction represents the type of authentication action
|
||||
type AuthAction string
|
||||
|
||||
const (
|
||||
AuthActionClineLogin AuthAction = "cline_login"
|
||||
AuthActionBYOSetup AuthAction = "provider_setup"
|
||||
AuthActionChangeClineModel AuthAction = "change_cline_model"
|
||||
AuthActionSelectOrganization AuthAction = "select_organization"
|
||||
AuthActionSelectProvider AuthAction = "select_provider"
|
||||
AuthActionExit AuthAction = "exit_wizard"
|
||||
)
|
||||
|
||||
// Cline Auth Menu
|
||||
// Example Layout
|
||||
//
|
||||
// ┃ Cline Account: <authenticated/not authenticated>
|
||||
// ┃ Active Provider: <provider name or none configured>
|
||||
// ┃ Active Model: <model name or none configured>
|
||||
// ┃
|
||||
// ┃ What would you like to do?
|
||||
// ┃ Change Cline model (only if authenticated) - hidden if not authenticated
|
||||
// ┃ Authenticate with Cline account / Sign out of Cline - changes based on auth status
|
||||
// ┃ Select active provider (Cline or BYO) - always shown. Used to switch between Cline and BYO providers
|
||||
// ┃ Configure BYO API providers - always shown. Launches provider setup wizard
|
||||
// ┃ Exit authorization wizard - always shown. Exits the auth menu
|
||||
|
||||
// RunAuthFlow is the entry point for the entire auth flow with instance management
|
||||
// It spawns a fresh instance for auth operations and cleans it up when done
|
||||
func RunAuthFlow(ctx context.Context, args []string) error {
|
||||
// Spawn a fresh instance for auth operations
|
||||
instanceInfo, err := global.Clients.StartNewInstance(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start auth instance: %w", err)
|
||||
}
|
||||
|
||||
// Cleanup when done (success, error, or panic)
|
||||
defer func() {
|
||||
verboseLog("Shutting down auth instance at %s", instanceInfo.Address)
|
||||
if err := global.KillInstanceByAddress(context.Background(), global.Clients.GetRegistry(), instanceInfo.Address); err != nil {
|
||||
verboseLog("Warning: Failed to kill auth instance: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Store instance address in context for all auth handlers to use
|
||||
authCtx := context.WithValue(ctx, authInstanceAddressKey, instanceInfo.Address)
|
||||
|
||||
// Route to existing auth flow
|
||||
return HandleAuthCommand(authCtx, args)
|
||||
}
|
||||
|
||||
// Main entry point for handling the `cline auth` command
|
||||
// HandleAuthCommand routes the auth command based on the number of arguments
|
||||
func HandleAuthCommand(ctx context.Context, args []string) error {
|
||||
|
||||
// Check if flags are provided for quick setup
|
||||
if QuickProvider != "" || QuickAPIKey != "" || QuickModelID != "" || QuickBaseURL != "" {
|
||||
if QuickProvider == "" || QuickAPIKey == "" || QuickModelID == "" {
|
||||
return fmt.Errorf("quick setup requires --provider, --apikey, and --modelid flags. Use 'cline auth --help' for more information")
|
||||
}
|
||||
return QuickSetupFromFlags(ctx, QuickProvider, QuickAPIKey, QuickModelID, QuickBaseURL)
|
||||
}
|
||||
|
||||
switch len(args) {
|
||||
case 0:
|
||||
// No args: Show uth wizard
|
||||
return HandleAuthMenuNoArgs(ctx)
|
||||
case 1, 2, 3, 4:
|
||||
fmt.Println("Invalid positional arguments. Correct usage:")
|
||||
fmt.Println(" cline auth --provider <provider> --apikey <key> --modelid <model> --baseurl <optional>")
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("too many arguments. Use flags for quick setup: --provider, --apikey, --modelid --baseurl(optional)")
|
||||
}
|
||||
}
|
||||
|
||||
// getAuthInstanceAddress retrieves the auth instance address from context
|
||||
// Returns empty string if not found (falls back to default behavior)
|
||||
func getAuthInstanceAddress(ctx context.Context) string {
|
||||
if addr, ok := ctx.Value(authInstanceAddressKey).(string); ok {
|
||||
return addr
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// HandleAuthMenuNoArgs prepares the auth menu when no arguments are provided
|
||||
func HandleAuthMenuNoArgs(ctx context.Context) error {
|
||||
// Check if Cline is authenticated
|
||||
isClineAuth := IsAuthenticated(ctx)
|
||||
|
||||
// Get current provider config for display
|
||||
var currentProvider string
|
||||
var currentModel string
|
||||
if manager, err := createTaskManager(ctx); err == nil {
|
||||
if providerList, err := GetProviderConfigurations(ctx, manager); err == nil {
|
||||
if providerList.ActProvider != nil {
|
||||
currentProvider = GetProviderDisplayName(providerList.ActProvider.Provider)
|
||||
currentModel = providerList.ActProvider.ModelID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch organizations if authenticated
|
||||
var hasOrganizations bool
|
||||
if isClineAuth {
|
||||
if client, err := global.GetDefaultClient(ctx); err == nil {
|
||||
if orgsResponse, err := client.Account.GetUserOrganizations(ctx, &cline.EmptyRequest{}); err == nil {
|
||||
hasOrganizations = len(orgsResponse.GetOrganizations()) > 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
action, err := ShowAuthMenuWithStatus(isClineAuth, hasOrganizations, currentProvider, currentModel)
|
||||
if err != nil {
|
||||
// Check if user cancelled - propagate for clean exit
|
||||
if err == huh.ErrUserAborted {
|
||||
return huh.ErrUserAborted
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
switch action {
|
||||
case AuthActionClineLogin:
|
||||
return HandleClineAuth(ctx)
|
||||
case AuthActionBYOSetup:
|
||||
return HandleAPIProviderSetup(ctx)
|
||||
case AuthActionChangeClineModel:
|
||||
return HandleChangeClineModel(ctx)
|
||||
case AuthActionSelectOrganization:
|
||||
return HandleSelectOrganization(ctx)
|
||||
case AuthActionSelectProvider:
|
||||
return HandleSelectProvider(ctx)
|
||||
case AuthActionExit:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("invalid action")
|
||||
}
|
||||
}
|
||||
|
||||
// ShowAuthMenuWithStatus displays the main auth menu with Cline + provider status
|
||||
func ShowAuthMenuWithStatus(isClineAuthenticated bool, hasOrganizations bool, currentProvider, currentModel string) (AuthAction, error) {
|
||||
var action AuthAction
|
||||
var options []huh.Option[AuthAction]
|
||||
|
||||
// Build menu options based on authentication status
|
||||
if isClineAuthenticated {
|
||||
options = []huh.Option[AuthAction]{
|
||||
huh.NewOption("Change Cline model", AuthActionChangeClineModel),
|
||||
}
|
||||
|
||||
// Add organization selection if user has organizations
|
||||
if hasOrganizations {
|
||||
options = append(options, huh.NewOption("Select organization", AuthActionSelectOrganization))
|
||||
}
|
||||
|
||||
options = append(options,
|
||||
huh.NewOption("Sign out of Cline", AuthActionClineLogin),
|
||||
huh.NewOption("Select active provider (Cline or BYO)", AuthActionSelectProvider),
|
||||
huh.NewOption("Configure BYO API providers", AuthActionBYOSetup),
|
||||
huh.NewOption("Exit authorization wizard", AuthActionExit),
|
||||
)
|
||||
} else {
|
||||
options = []huh.Option[AuthAction]{
|
||||
huh.NewOption("Authenticate with Cline account", AuthActionClineLogin),
|
||||
huh.NewOption("Select active provider (Cline or BYO)", AuthActionSelectProvider),
|
||||
huh.NewOption("Configure BYO API providers", AuthActionBYOSetup),
|
||||
huh.NewOption("Exit authorization wizard", AuthActionExit),
|
||||
}
|
||||
}
|
||||
|
||||
// Determine menu title based on status
|
||||
var title string
|
||||
renderer := display.NewRenderer(global.Config.OutputFormat)
|
||||
|
||||
// Always show Cline authentication status
|
||||
if isClineAuthenticated {
|
||||
title = fmt.Sprintf("Cline Account: %s Authenticated\n", renderer.Green("✓"))
|
||||
} else {
|
||||
title = fmt.Sprintf("Cline Account: %s Not authenticated\n", renderer.Red("✗"))
|
||||
}
|
||||
|
||||
// Show active provider and model if configured (regardless of Cline auth status)
|
||||
if currentProvider != "" && currentModel != "" {
|
||||
title += fmt.Sprintf("Active Provider: %s\nActive Model: %s\n",
|
||||
renderer.White(currentProvider),
|
||||
renderer.White(currentModel))
|
||||
}
|
||||
|
||||
// Always end with a huh?
|
||||
title += "\nWhat would you like to do?"
|
||||
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[AuthAction]().
|
||||
Title(title).
|
||||
Options(options...).
|
||||
Value(&action),
|
||||
),
|
||||
)
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
// Check if user cancelled with Control-C
|
||||
if err == huh.ErrUserAborted {
|
||||
// Return the error to allow deferred cleanup to run
|
||||
return "", huh.ErrUserAborted
|
||||
}
|
||||
return "", fmt.Errorf("failed to get menu choice: %w", err)
|
||||
}
|
||||
|
||||
return action, nil
|
||||
}
|
||||
|
||||
// HandleAPIProviderSetup launches the API provider configuration wizard
|
||||
func HandleAPIProviderSetup(ctx context.Context) error {
|
||||
wizard, err := NewProviderWizard(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create provider wizard: %w", err)
|
||||
}
|
||||
|
||||
return wizard.Run()
|
||||
}
|
||||
|
||||
// HandleSelectProvider allows users to switch between Cline provider and BYO providers
|
||||
func HandleSelectProvider(ctx context.Context) error {
|
||||
// Get task manager
|
||||
manager, err := createTaskManager(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create task manager: %w", err)
|
||||
}
|
||||
|
||||
// Detect all providers with valid configurations (is an API key present)
|
||||
availableProviders, err := DetectAllConfiguredProviders(ctx, manager)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to detect configured providers: %w", err)
|
||||
}
|
||||
|
||||
// Build list of available providers
|
||||
var providerOptions []huh.Option[string]
|
||||
var providerMapping = make(map[string]cline.ApiProvider)
|
||||
|
||||
// Add each configured provider to the selection menu
|
||||
for _, provider := range availableProviders {
|
||||
providerName := GetProviderDisplayName(provider)
|
||||
providerKey := fmt.Sprintf("provider_%d", provider)
|
||||
providerOptions = append(providerOptions, huh.NewOption(providerName, providerKey))
|
||||
providerMapping[providerKey] = provider
|
||||
}
|
||||
|
||||
if len(providerOptions) == 0 {
|
||||
fmt.Println("No providers available. Please configure a provider first.")
|
||||
return HandleAuthMenuNoArgs(ctx)
|
||||
}
|
||||
|
||||
providerOptions = append(providerOptions, huh.NewOption("(Cancel)", "cancel"))
|
||||
|
||||
// Show selection menu
|
||||
var selected string
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("Select which provider to use").
|
||||
Options(providerOptions...).
|
||||
Value(&selected),
|
||||
),
|
||||
)
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
// Check if user cancelled with Control-C
|
||||
if err == huh.ErrUserAborted {
|
||||
return huh.ErrUserAborted
|
||||
}
|
||||
return fmt.Errorf("failed to select provider: %w", err)
|
||||
}
|
||||
|
||||
if selected == "cancel" {
|
||||
return HandleAuthMenuNoArgs(ctx)
|
||||
}
|
||||
|
||||
// Get the selected provider
|
||||
selectedProvider := providerMapping[selected]
|
||||
|
||||
// Apply the selected provider
|
||||
if selectedProvider == cline.ApiProvider_CLINE {
|
||||
// Configure Cline as the active provider
|
||||
return SelectClineModel(ctx, manager)
|
||||
} else {
|
||||
// Switch to the selected BYO provider
|
||||
return SwitchToBYOProvider(ctx, manager, selectedProvider)
|
||||
}
|
||||
}
|
||||
|
||||
// createTaskManager is a helper to create a task manager (avoids import cycles)
|
||||
// Uses the auth instance address from context if available, otherwise falls back to default
|
||||
func createTaskManager(ctx context.Context) (*task.Manager, error) {
|
||||
authAddr := getAuthInstanceAddress(ctx)
|
||||
if authAddr != "" {
|
||||
return task.NewManagerForAddress(ctx, authAddr)
|
||||
}
|
||||
return task.NewManagerForDefault(ctx)
|
||||
}
|
||||
|
||||
func verboseLog(format string, args ...interface{}) {
|
||||
if global.Config != nil && global.Config.Verbose {
|
||||
fmt.Printf("[VERBOSE] "+format+"\n", args...)
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
// AuthStatusListener manages subscription to auth status updates
|
||||
type AuthStatusListener struct {
|
||||
stream cline.AccountService_SubscribeToAuthStatusUpdateClient
|
||||
updatesCh chan *cline.AuthState
|
||||
errCh chan error
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// NewAuthStatusListener creates a new auth status listener
|
||||
func NewAuthStatusListener(parentCtx context.Context) (*AuthStatusListener, error) {
|
||||
client, err := global.GetDefaultClient(parentCtx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get client: %w", err)
|
||||
}
|
||||
|
||||
// Create cancellable context
|
||||
ctx, cancel := context.WithCancel(parentCtx)
|
||||
|
||||
// Subscribe to auth status updates
|
||||
stream, err := client.Account.SubscribeToAuthStatusUpdate(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("failed to subscribe to auth updates: %w", err)
|
||||
}
|
||||
|
||||
return &AuthStatusListener{
|
||||
stream: stream,
|
||||
updatesCh: make(chan *cline.AuthState, 10),
|
||||
errCh: make(chan error, 1),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Start begins listening to the auth status update stream
|
||||
func (l *AuthStatusListener) Start() error {
|
||||
verboseLog("Starting auth status listener...")
|
||||
|
||||
go l.readStream()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// readStream reads from the gRPC stream and forwards messages to channels
|
||||
func (l *AuthStatusListener) readStream() {
|
||||
defer close(l.updatesCh)
|
||||
defer close(l.errCh)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-l.ctx.Done():
|
||||
verboseLog("Auth listener context cancelled")
|
||||
return
|
||||
default:
|
||||
state, err := l.stream.Recv()
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
verboseLog("Auth status stream closed")
|
||||
return
|
||||
}
|
||||
verboseLog("Error reading from auth status stream: %v", err)
|
||||
select {
|
||||
case l.errCh <- err:
|
||||
case <-l.ctx.Done():
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
verboseLog("Received auth state update: user=%v", state.User != nil)
|
||||
|
||||
select {
|
||||
case l.updatesCh <- state:
|
||||
case <-l.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WaitForAuthentication blocks until authentication succeeds or timeout occurs
|
||||
func (l *AuthStatusListener) WaitForAuthentication(timeout time.Duration) error {
|
||||
verboseLog("Waiting for authentication (timeout: %v)...", timeout)
|
||||
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-timer.C:
|
||||
return fmt.Errorf("authentication timeout after %v - please try again", timeout)
|
||||
|
||||
case <-l.ctx.Done():
|
||||
return fmt.Errorf("authentication cancelled")
|
||||
|
||||
case err := <-l.errCh:
|
||||
return fmt.Errorf("authentication stream error: %w", err)
|
||||
|
||||
case state := <-l.updatesCh:
|
||||
if isAuthenticated(state) {
|
||||
verboseLog("Authentication successful!")
|
||||
return nil
|
||||
}
|
||||
verboseLog("Received auth update but not authenticated yet...")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop closes the stream and cleans up resources
|
||||
func (l *AuthStatusListener) Stop() {
|
||||
verboseLog("Stopping auth status listener...")
|
||||
l.cancel()
|
||||
}
|
||||
|
||||
// isAuthenticated checks if AuthState indicates successful authentication
|
||||
func isAuthenticated(state *cline.AuthState) bool {
|
||||
return state != nil && state.User != nil
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/task"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
// Package-level variables for command-line flags
|
||||
var (
|
||||
QuickProvider string // Provider ID (e.g., "openai", "anthropic")
|
||||
QuickAPIKey string // API key for the provider
|
||||
QuickModelID string // Model ID to configure
|
||||
QuickBaseURL string // Base URL (optional, for openai compatible only)
|
||||
)
|
||||
|
||||
// QuickSetupFromFlags performs quick setup using command-line flags
|
||||
// Returns error if validation fails or configuration cannot be applied
|
||||
func QuickSetupFromFlags(ctx context.Context, provider, apiKey, modelID, baseURL string) error {
|
||||
// Validate all input parameters
|
||||
providerEnum, err := validateQuickSetupInputs(provider, apiKey, modelID, baseURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create task manager for state operations
|
||||
manager, err := createTaskManager(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create task manager: %w", err)
|
||||
}
|
||||
|
||||
// Validate and fetch model information if needed
|
||||
finalModelID, modelInfo, err := validateAndFetchModel(ctx, manager, providerEnum, modelID, apiKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("model validation failed: %w", err)
|
||||
}
|
||||
|
||||
// For Ollama, baseURL is stored in the API key field
|
||||
finalAPIKey := apiKey
|
||||
finalBaseURL := baseURL
|
||||
if providerEnum == cline.ApiProvider_OLLAMA {
|
||||
if baseURL != "" {
|
||||
finalAPIKey = baseURL
|
||||
finalBaseURL = ""
|
||||
} else if apiKey != "" {
|
||||
// User provided API key for Ollama - treat it as baseURL
|
||||
finalAPIKey = apiKey
|
||||
finalBaseURL = ""
|
||||
} else {
|
||||
// Use default Ollama baseURL
|
||||
finalAPIKey = "http://localhost:11434"
|
||||
finalBaseURL = ""
|
||||
}
|
||||
}
|
||||
|
||||
// Configure the provider using existing AddProviderPartial function
|
||||
if err := AddProviderPartial(ctx, manager, providerEnum, finalModelID, finalAPIKey, finalBaseURL, modelInfo); err != nil {
|
||||
return fmt.Errorf("failed to configure provider: %w", err)
|
||||
}
|
||||
|
||||
// Set the provider as active for both Plan and Act modes
|
||||
if err := UpdateProviderPartial(ctx, manager, providerEnum, ProviderUpdatesPartial{}, true); err != nil {
|
||||
return fmt.Errorf("failed to set provider as active: %w", err)
|
||||
}
|
||||
|
||||
// Mark welcome view as completed
|
||||
if err := markWelcomeViewCompleted(ctx, manager); err != nil {
|
||||
// Non-fatal error, just log it
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("[DEBUG] Warning: failed to mark welcome view as completed: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Flush pending state changes to disk immediately
|
||||
// This ensures all configuration changes are persisted before the instance terminates
|
||||
if _, err := manager.GetClient().State.FlushPendingState(ctx, &cline.EmptyRequest{}); err != nil {
|
||||
return fmt.Errorf("failed to flush pending state: %w", err)
|
||||
}
|
||||
|
||||
// Success message
|
||||
fmt.Printf("\n✓ Successfully configured %s provider\n", GetProviderDisplayName(providerEnum))
|
||||
fmt.Printf(" Model: %s\n", finalModelID)
|
||||
if providerEnum == cline.ApiProvider_OLLAMA {
|
||||
fmt.Printf(" Base URL: %s\n", finalAPIKey)
|
||||
} else {
|
||||
fmt.Println(" API Key: Configured")
|
||||
}
|
||||
if finalBaseURL != "" {
|
||||
fmt.Printf(" Custom Base URL: %s\n", finalBaseURL)
|
||||
}
|
||||
fmt.Println("\nYou can now use Cline with this provider.")
|
||||
fmt.Println("Run 'cline start' to begin a new task.")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateQuickSetupInputs validates all input parameters for quick setup
|
||||
// Returns the validated provider enum or an error if validation fails
|
||||
func validateQuickSetupInputs(provider, apiKey, modelID, baseURL string) (cline.ApiProvider, error) {
|
||||
// Validate required parameters
|
||||
if provider == "" {
|
||||
return cline.ApiProvider_ANTHROPIC, fmt.Errorf("provider is required. Use --provider or -p flag")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(apiKey) == "" && provider != "ollama" {
|
||||
return cline.ApiProvider_ANTHROPIC, fmt.Errorf("API key is required for %s provider. Use --apikey or -k flag", provider)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(modelID) == "" {
|
||||
return cline.ApiProvider_ANTHROPIC, fmt.Errorf("model ID is required. Use --modelid or -m flag")
|
||||
}
|
||||
|
||||
// Validate and map provider string to enum
|
||||
providerEnum, err := validateQuickSetupProvider(provider)
|
||||
if err != nil {
|
||||
return cline.ApiProvider_ANTHROPIC, err
|
||||
}
|
||||
|
||||
// Validate that baseURL is only provided for OpenAI-compatible providers
|
||||
if err := validateBaseURL(baseURL, providerEnum); err != nil {
|
||||
return cline.ApiProvider_ANTHROPIC, err
|
||||
}
|
||||
|
||||
return providerEnum, nil
|
||||
}
|
||||
|
||||
// validateBaseURL checks if the user's input includes a baseURL for a provider other than OpenAI (compatible)
|
||||
// Returns error if baseURL is provided for unsupported providers
|
||||
func validateBaseURL(baseURL string, providerEnum cline.ApiProvider) error {
|
||||
if providerEnum != cline.ApiProvider_OPENAI {
|
||||
if baseURL != "" {
|
||||
return fmt.Errorf("base URL is only supported for OpenAI and OpenAI-compatible providers")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
// validateQuickSetupProvider validates the provider ID and returns the enum value
|
||||
// Returns error if provider is invalid or not supported for quick setup
|
||||
func validateQuickSetupProvider(providerID string) (cline.ApiProvider, error) {
|
||||
// Normalize provider ID (trim whitespace, lowercase)
|
||||
normalizedID := strings.TrimSpace(strings.ToLower(providerID))
|
||||
|
||||
// Explicitly block Bedrock
|
||||
if normalizedID == "bedrock" {
|
||||
return cline.ApiProvider_BEDROCK, fmt.Errorf("bedrock provider is not supported for quick setup due to complex authentication requirements. Please use interactive setup: cline auth")
|
||||
}
|
||||
|
||||
// Map provider string to enum using existing function
|
||||
provider, ok := mapProviderStringToEnum(normalizedID)
|
||||
if !ok {
|
||||
// Provider not found - provide helpful error message
|
||||
supportedProviders := []string{
|
||||
"openai-native", "openai", "anthropic", "gemini",
|
||||
"openrouter", "xai", "cerebras", "ollama",
|
||||
}
|
||||
return cline.ApiProvider_ANTHROPIC, fmt.Errorf(
|
||||
"invalid provider '%s'. Supported providers: %s",
|
||||
providerID,
|
||||
strings.Join(supportedProviders, ", "),
|
||||
)
|
||||
}
|
||||
|
||||
// Validate against supported quick setup providers
|
||||
supportedProviders := map[cline.ApiProvider]bool{
|
||||
cline.ApiProvider_OPENAI_NATIVE: true,
|
||||
cline.ApiProvider_OPENAI: true,
|
||||
cline.ApiProvider_ANTHROPIC: true,
|
||||
cline.ApiProvider_GEMINI: true,
|
||||
cline.ApiProvider_OPENROUTER: true,
|
||||
cline.ApiProvider_XAI: true,
|
||||
cline.ApiProvider_CEREBRAS: true,
|
||||
cline.ApiProvider_OLLAMA: true,
|
||||
cline.ApiProvider_NOUSRESEARCH: true,
|
||||
}
|
||||
|
||||
if !supportedProviders[provider] {
|
||||
return provider, fmt.Errorf(
|
||||
"provider '%s' is not supported for quick setup. Please use interactive setup: cline auth",
|
||||
providerID,
|
||||
)
|
||||
}
|
||||
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
// validateAndFetchModel validates the model ID or fetches from provider if needed
|
||||
// Returns the final model ID and optional model info
|
||||
// For providers with static models, validates against the list
|
||||
// For providers with dynamic models, fetches the list if possible
|
||||
func validateAndFetchModel(ctx context.Context, manager *task.Manager, provider cline.ApiProvider, modelID, apiKey string) (string, interface{}, error) {
|
||||
// Normalize model ID
|
||||
modelID = strings.TrimSpace(modelID)
|
||||
if modelID == "" {
|
||||
return "", nil, fmt.Errorf("model ID cannot be empty")
|
||||
}
|
||||
|
||||
// For most providers, we trust the user's input since we can't easily validate without making API calls
|
||||
// The actual validation will happen when the model is used
|
||||
switch provider {
|
||||
case cline.ApiProvider_OPENROUTER:
|
||||
// OpenRouter supports model info fetching, but it requires an API call
|
||||
// For quick setup, we'll trust the user's input and return nil for model info
|
||||
// The actual model info will be fetched when needed
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("[DEBUG] OpenRouter model ID: %s (will be validated on first use)\n", modelID)
|
||||
}
|
||||
return modelID, nil, nil
|
||||
|
||||
case cline.ApiProvider_OLLAMA:
|
||||
// Ollama models can be validated by fetching the list, but this requires the server to be running
|
||||
// For quick setup, we'll trust the user's input
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("[DEBUG] Ollama model ID: %s (will be validated when server is accessible)\n", modelID)
|
||||
}
|
||||
return modelID, nil, nil
|
||||
|
||||
default:
|
||||
// For other providers (Anthropic, OpenAI, Gemini, XAI, Cerebras), trust user input
|
||||
// Model validation will occur when the model is actually used
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("[DEBUG] %s model ID: %s (will be validated on first use)\n", GetProviderDisplayName(provider), modelID)
|
||||
}
|
||||
return modelID, nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// markWelcomeViewCompleted marks the welcome view as completed in the state
|
||||
// This prevents the welcome view from showing up after quick setup
|
||||
func markWelcomeViewCompleted(ctx context.Context, manager *task.Manager) error {
|
||||
// Use the State service to update the welcome view flag
|
||||
_, err := manager.GetClient().State.SetWelcomeViewCompleted(ctx, &cline.BooleanRequest{Value: true})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to mark welcome view as completed: %w", err)
|
||||
}
|
||||
|
||||
if global.Config.Verbose {
|
||||
fmt.Println("[DEBUG] Marked welcome view as completed")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/task"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
// DefaultClineModelID is the default model ID for Cline provider.
|
||||
// Cline uses OpenRouter-compatible model IDs.
|
||||
const DefaultClineModelID = "anthropic/claude-sonnet-4.5"
|
||||
|
||||
// FetchClineModels fetches available Cline models from Cline Core.
|
||||
// Note: Cline provider uses OpenRouter-compatible API and model format.
|
||||
// The models are fetched using the same method as OpenRouter.
|
||||
func FetchClineModels(ctx context.Context, manager *task.Manager) (map[string]*cline.OpenRouterModelInfo, error) {
|
||||
if global.Config.Verbose {
|
||||
fmt.Println("Fetching Cline models (using OpenRouter-compatible API)")
|
||||
}
|
||||
|
||||
// Cline uses OpenRouter model fetching
|
||||
models, err := FetchOpenRouterModels(ctx, manager)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch Cline models: %w", err)
|
||||
}
|
||||
|
||||
return models, nil
|
||||
}
|
||||
|
||||
// GetClineModelInfo retrieves information for a specific Cline model.
|
||||
func GetClineModelInfo(modelID string, models map[string]*cline.OpenRouterModelInfo) (*cline.OpenRouterModelInfo, error) {
|
||||
modelInfo, exists := models[modelID]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("model %s not found", modelID)
|
||||
}
|
||||
return modelInfo, nil
|
||||
}
|
||||
|
||||
// SetDefaultClineModel configures the default Cline model after authentication.
|
||||
// This is called automatically after successful Cline sign-in.
|
||||
func SetDefaultClineModel(ctx context.Context, manager *task.Manager) error {
|
||||
|
||||
// Fetch available models
|
||||
models, err := FetchClineModels(ctx, manager)
|
||||
if err != nil {
|
||||
// If we can't fetch models, we'll use the default without model info
|
||||
fmt.Printf("Warning: Could not fetch Cline models: %v\n", err)
|
||||
fmt.Printf("Using default model: %s\n", DefaultClineModelID)
|
||||
return applyDefaultClineModel(ctx, manager, nil)
|
||||
}
|
||||
|
||||
// Check if default model is available
|
||||
modelInfo, err := GetClineModelInfo(DefaultClineModelID, models)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: Default model not found: %v\n", err)
|
||||
// Try to use any available model
|
||||
for modelID := range models {
|
||||
fmt.Printf("Using available model: %s\n", modelID)
|
||||
return applyClineModelConfiguration(ctx, manager, modelID, models[modelID])
|
||||
}
|
||||
return fmt.Errorf("no usable Cline models found")
|
||||
}
|
||||
|
||||
if err := applyClineModelConfiguration(ctx, manager, DefaultClineModelID, modelInfo); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := setWelcomeViewCompletedWithManager(ctx, manager); err != nil {
|
||||
verboseLog("Warning: Failed to mark welcome view as completed: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SelectClineModel presents a menu to select a Cline model and applies the configuration.
|
||||
func SelectClineModel(ctx context.Context, manager *task.Manager) error {
|
||||
|
||||
// Fetch models (uses OpenRouter-compatible format)
|
||||
models, err := FetchClineModels(ctx, manager)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch Cline models: %w", err)
|
||||
}
|
||||
|
||||
// Convert to interface map for generic utilities
|
||||
modelMap := ConvertOpenRouterModelsToInterface(models)
|
||||
|
||||
// Get model IDs as a sorted list
|
||||
modelIDs := ConvertModelsMapToSlice(modelMap)
|
||||
|
||||
// Display selection menu
|
||||
selectedModelID, err := DisplayModelSelectionMenu(modelIDs, "Cline")
|
||||
if err != nil {
|
||||
return fmt.Errorf("model selection failed: %w", err)
|
||||
}
|
||||
|
||||
// Get the selected model info
|
||||
modelInfo := models[selectedModelID]
|
||||
|
||||
// Apply the configuration
|
||||
if err := applyClineModelConfiguration(ctx, manager, selectedModelID, modelInfo); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
|
||||
// Return to main auth menu after model selection
|
||||
return HandleAuthMenuNoArgs(ctx)
|
||||
}
|
||||
|
||||
// applyClineModelConfiguration applies a Cline model configuration to both Act and Plan modes using UpdateProviderPartial.
|
||||
// Cline uses OpenRouter-compatible model format.
|
||||
func applyClineModelConfiguration(ctx context.Context, manager *task.Manager, modelID string, modelInfo *cline.OpenRouterModelInfo) error {
|
||||
provider := cline.ApiProvider_CLINE
|
||||
|
||||
updates := ProviderUpdatesPartial{
|
||||
ModelID: &modelID,
|
||||
ModelInfo: modelInfo,
|
||||
}
|
||||
|
||||
return UpdateProviderPartial(ctx, manager, provider, updates, true)
|
||||
}
|
||||
|
||||
func applyDefaultClineModel(ctx context.Context, manager *task.Manager, modelInfo *cline.OpenRouterModelInfo) error {
|
||||
if err := applyClineModelConfiguration(ctx, manager, DefaultClineModelID, modelInfo); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := setWelcomeViewCompletedWithManager(ctx, manager); err != nil {
|
||||
verboseLog("Warning: Failed to mark welcome view as completed: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func setWelcomeViewCompletedWithManager(ctx context.Context, manager *task.Manager) error {
|
||||
_, err := manager.GetClient().State.SetWelcomeViewCompleted(ctx, &cline.BooleanRequest{Value: true})
|
||||
return err
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/cline/cli/pkg/cli/task"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// FetchOpenRouterModels fetches available OpenRouter models from Cline Core
|
||||
func FetchOpenRouterModels(ctx context.Context, manager *task.Manager) (map[string]*cline.OpenRouterModelInfo, error) {
|
||||
resp, err := manager.GetClient().Models.RefreshOpenRouterModelsRpc(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch OpenRouter models: %w", err)
|
||||
}
|
||||
return resp.Models, nil
|
||||
}
|
||||
|
||||
// FetchOcaModels fetches available Oca models from Cline Core
|
||||
func FetchOcaModels(ctx context.Context, manager *task.Manager) (map[string]*cline.OcaModelInfo, error) {
|
||||
resp, err := manager.GetClient().Models.RefreshOcaModels(ctx, &cline.StringRequest{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch Oca models: %w", err)
|
||||
}
|
||||
return resp.Models, nil
|
||||
}
|
||||
|
||||
// ConvertOpenRouterModelsToInterface converts OpenRouter model map to generic interface map.
|
||||
// This allows OpenRouter and Cline models to be used with the generic fetching utilities.
|
||||
func ConvertOpenRouterModelsToInterface(models map[string]*cline.OpenRouterModelInfo) map[string]interface{} {
|
||||
result := make(map[string]interface{}, len(models))
|
||||
for k, v := range models {
|
||||
result[k] = v
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
// FetchOpenAiModels fetches available OpenAI models from Cline Core
|
||||
// Takes the API key and returns a list of model IDs
|
||||
func FetchOpenAiModels(ctx context.Context, manager *task.Manager, baseURL, apiKey string) ([]string, error) {
|
||||
req := &cline.OpenAiModelsRequest{
|
||||
BaseUrl: baseURL,
|
||||
ApiKey: apiKey,
|
||||
}
|
||||
|
||||
resp, err := manager.GetClient().Models.RefreshOpenAiModels(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch OpenAI models: %w", err)
|
||||
}
|
||||
return resp.Values, nil
|
||||
}
|
||||
|
||||
// FetchOllamaModels fetches available Ollama models from Cline Core
|
||||
// Takes the base URL (empty string for default) and returns a list of model IDs
|
||||
func FetchOllamaModels(ctx context.Context, manager *task.Manager, baseURL string) ([]string, error) {
|
||||
req := &cline.StringRequest{
|
||||
Value: baseURL,
|
||||
}
|
||||
|
||||
resp, err := manager.GetClient().Models.GetOllamaModels(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch Ollama models: %w", err)
|
||||
}
|
||||
return resp.Values, nil
|
||||
}
|
||||
|
||||
// DisplayModelSelectionMenu shows an interactive menu for selecting a model from a list.
|
||||
// Models are displayed alphabetically. Uses model ID as the option value to avoid
|
||||
// index-based bugs when list order changes.
|
||||
// Returns the selected model ID.
|
||||
func DisplayModelSelectionMenu(models []string, providerName string) (string, error) {
|
||||
if len(models) == 0 {
|
||||
return "", fmt.Errorf("no models available for selection")
|
||||
}
|
||||
|
||||
// Use model ID as the value (not index) to avoid positional coupling bugs
|
||||
var selectedModel string
|
||||
options := make([]huh.Option[string], len(models))
|
||||
for i, model := range models {
|
||||
options[i] = huh.NewOption(model, model)
|
||||
}
|
||||
|
||||
title := fmt.Sprintf("Select a %s model", providerName)
|
||||
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title(title).
|
||||
Options(options...).
|
||||
Height(calculateSelectHeight()).
|
||||
Filtering(true).
|
||||
Value(&selectedModel),
|
||||
),
|
||||
)
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return "", fmt.Errorf("failed to select model: %w", err)
|
||||
}
|
||||
|
||||
return selectedModel, nil
|
||||
}
|
||||
|
||||
// ConvertModelsMapToSlice converts a map of models to a sorted slice of model IDs.
|
||||
// This is useful for displaying models in a consistent order in UI components.
|
||||
func ConvertModelsMapToSlice(models map[string]interface{}) []string {
|
||||
result := make([]string, 0, len(models))
|
||||
for modelID := range models {
|
||||
result = append(result, modelID)
|
||||
}
|
||||
|
||||
// Sort alphabetically for consistent display
|
||||
sort.Strings(result)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ConvertOcaModelsToInterface converts Oca model map to generic interface map.
|
||||
// This allows Oca and Cline models to be used with the generic fetching utilities.
|
||||
func ConvertOcaModelsToInterface(models map[string]*cline.OcaModelInfo) map[string]interface{} {
|
||||
result := make(map[string]interface{}, len(models))
|
||||
for k, v := range models {
|
||||
result[k] = v
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// getTerminalHeight returns the terminal height (rows)
|
||||
func getTerminalHeight() int {
|
||||
_, height, err := term.GetSize(int(os.Stdout.Fd()))
|
||||
if err != nil || height <= 0 {
|
||||
return 25 // safe fallback for non-TTY or errors
|
||||
}
|
||||
return height
|
||||
}
|
||||
|
||||
// calculateSelectHeight computes appropriate height for Select component
|
||||
// Reserves space for title, search UI, and margins
|
||||
func calculateSelectHeight() int {
|
||||
height := getTerminalHeight()
|
||||
// Reserve ~10 rows for UI chrome (title, search, margins)
|
||||
visibleRows := height - 10
|
||||
// Clamp between 8 (minimum usable) and 25 (maximum before unwieldy)
|
||||
if visibleRows < 8 {
|
||||
return 8
|
||||
}
|
||||
if visibleRows > 25 {
|
||||
return 25
|
||||
}
|
||||
return visibleRows
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/cline/cli/pkg/generated"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
// SupportsStaticModelList returns true if the provider has a predefined static model list
|
||||
func SupportsStaticModelList(provider cline.ApiProvider) bool {
|
||||
providerID := GetProviderIDForEnum(provider)
|
||||
if providerID == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if this provider has static models defined
|
||||
def, err := generated.GetProviderDefinition(providerID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Return true if provider has models and isn't dynamic-only
|
||||
// (Dynamic providers like OpenRouter/OpenAI/Ollama fetch from API)
|
||||
return len(def.Models) > 0 && !def.HasDynamicModels
|
||||
}
|
||||
|
||||
// FetchStaticModels retrieves the static model list for a provider from generated definitions
|
||||
// Returns a sorted list of model IDs and a map of model IDs to their info
|
||||
func FetchStaticModels(provider cline.ApiProvider) ([]string, map[string]generated.ModelInfo, error) {
|
||||
providerID := GetProviderIDForEnum(provider)
|
||||
if providerID == "" {
|
||||
return nil, nil, fmt.Errorf("unknown provider enum: %v", provider)
|
||||
}
|
||||
|
||||
def, err := generated.GetProviderDefinition(providerID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to get provider definition: %w", err)
|
||||
}
|
||||
|
||||
if len(def.Models) == 0 {
|
||||
return nil, nil, fmt.Errorf("no models defined for provider %s", providerID)
|
||||
}
|
||||
|
||||
// Extract model IDs and sort them
|
||||
modelIDs := make([]string, 0, len(def.Models))
|
||||
for modelID := range def.Models {
|
||||
modelIDs = append(modelIDs, modelID)
|
||||
}
|
||||
sort.Strings(modelIDs)
|
||||
|
||||
return modelIDs, def.Models, nil
|
||||
}
|
||||
|
||||
// GetDefaultModelForProvider returns the default model ID for a provider if one is defined
|
||||
func GetDefaultModelForProvider(provider cline.ApiProvider) string {
|
||||
providerID := GetProviderIDForEnum(provider)
|
||||
if providerID == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
def, err := generated.GetProviderDefinition(providerID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return def.DefaultModelID
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
// BYOProviderOption represents a selectable BYO (bring-your-own) provider option
|
||||
type BYOProviderOption struct {
|
||||
Name string
|
||||
Provider cline.ApiProvider
|
||||
}
|
||||
|
||||
// GetBYOProviderList returns the list of supported BYO providers for CLI configuration.
|
||||
// This list excludes Cline provider which is handled separately.
|
||||
func GetBYOProviderList() []BYOProviderOption {
|
||||
return []BYOProviderOption{
|
||||
{Name: "Anthropic", Provider: cline.ApiProvider_ANTHROPIC},
|
||||
{Name: "OpenAI Compatible", Provider: cline.ApiProvider_OPENAI},
|
||||
{Name: "OpenAI (Official)", Provider: cline.ApiProvider_OPENAI_NATIVE},
|
||||
{Name: "OpenRouter", Provider: cline.ApiProvider_OPENROUTER},
|
||||
{Name: "X AI (Grok)", Provider: cline.ApiProvider_XAI},
|
||||
{Name: "AWS Bedrock", Provider: cline.ApiProvider_BEDROCK},
|
||||
{Name: "Google Gemini", Provider: cline.ApiProvider_GEMINI},
|
||||
{Name: "Ollama", Provider: cline.ApiProvider_OLLAMA},
|
||||
{Name: "Cerebras", Provider: cline.ApiProvider_CEREBRAS},
|
||||
{Name: "NousResearch", Provider: cline.ApiProvider_NOUSRESEARCH},
|
||||
{Name: "Oracle Code Assist", Provider: cline.ApiProvider_OCA},
|
||||
}
|
||||
}
|
||||
|
||||
// SelectBYOProvider displays a menu for selecting a BYO provider.
|
||||
func SelectBYOProvider() (cline.ApiProvider, error) {
|
||||
providers := GetBYOProviderList()
|
||||
var selectedIndex int
|
||||
|
||||
options := make([]huh.Option[int], len(providers)+1)
|
||||
for i, provider := range providers {
|
||||
options[i] = huh.NewOption(provider.Name, i)
|
||||
}
|
||||
options[len(providers)] = huh.NewOption("(Cancel)", -1)
|
||||
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[int]().
|
||||
Title("Select an API provider").
|
||||
Options(options...).
|
||||
Value(&selectedIndex),
|
||||
),
|
||||
)
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return 0, fmt.Errorf("failed to select provider: %w", err)
|
||||
}
|
||||
|
||||
if selectedIndex == -1 {
|
||||
return 0, fmt.Errorf("provider selection cancelled")
|
||||
}
|
||||
|
||||
return providers[selectedIndex].Provider, nil
|
||||
}
|
||||
|
||||
// SupportsBYOModelFetching returns true if the provider supports fetching models dynamically
|
||||
// from a remote API, or if it has a static list of predefined models.
|
||||
// This is used to determine whether to show a model list before prompting for manual entry.
|
||||
func SupportsBYOModelFetching(provider cline.ApiProvider) bool {
|
||||
switch provider {
|
||||
case cline.ApiProvider_OPENROUTER:
|
||||
return true
|
||||
case cline.ApiProvider_OPENAI:
|
||||
return true
|
||||
case cline.ApiProvider_OLLAMA:
|
||||
return true
|
||||
case cline.ApiProvider_OCA:
|
||||
return true
|
||||
}
|
||||
|
||||
return SupportsStaticModelList(provider)
|
||||
}
|
||||
|
||||
// GetBYOProviderPlaceholder returns a placeholder model ID for manual entry based on provider.
|
||||
func GetBYOProviderPlaceholder(provider cline.ApiProvider) string {
|
||||
switch provider {
|
||||
case cline.ApiProvider_ANTHROPIC:
|
||||
return "e.g., claude-sonnet-4-5-20250929"
|
||||
case cline.ApiProvider_OPENAI:
|
||||
return "e.g., openai/gpt-oss-120b"
|
||||
case cline.ApiProvider_OPENAI_NATIVE:
|
||||
return "e.g., gpt-5-2025-08-07"
|
||||
case cline.ApiProvider_OPENROUTER:
|
||||
return "e.g., google/gemini-2.0-flash-exp:free"
|
||||
case cline.ApiProvider_XAI:
|
||||
return "e.g., grok-code-fast-1"
|
||||
case cline.ApiProvider_BEDROCK:
|
||||
return "e.g., anthropic.claude-sonnet-4-5-20250929-v1:0"
|
||||
case cline.ApiProvider_GEMINI:
|
||||
return "e.g., gemini-2.5-pro"
|
||||
case cline.ApiProvider_OLLAMA:
|
||||
return "e.g., qwen3-coder:30b"
|
||||
case cline.ApiProvider_CEREBRAS:
|
||||
return "e.g., gpt-oss-120b"
|
||||
case cline.ApiProvider_NOUSRESEARCH:
|
||||
return "e.g., Hermes-4-405B"
|
||||
case cline.ApiProvider_OCA:
|
||||
return "e.g., oca/llama4"
|
||||
default:
|
||||
return "Enter model ID"
|
||||
}
|
||||
}
|
||||
|
||||
// GetBYOAPIKeyFieldConfig returns field configuration for API key input based on provider.
|
||||
type APIKeyFieldConfig struct {
|
||||
Title string
|
||||
EchoMode huh.EchoMode
|
||||
IsRequired bool
|
||||
}
|
||||
|
||||
// GetBYOAPIKeyFieldConfig returns the configuration for the API key field based on provider.
|
||||
func GetBYOAPIKeyFieldConfig(provider cline.ApiProvider) APIKeyFieldConfig {
|
||||
if provider == cline.ApiProvider_OLLAMA {
|
||||
return APIKeyFieldConfig{
|
||||
Title: "Base URL (optional, press Enter for default)",
|
||||
EchoMode: huh.EchoModeNormal,
|
||||
IsRequired: false,
|
||||
}
|
||||
}
|
||||
|
||||
return APIKeyFieldConfig{
|
||||
Title: "API Key",
|
||||
EchoMode: huh.EchoModePassword,
|
||||
IsRequired: true,
|
||||
}
|
||||
}
|
||||
|
||||
// PromptForAPIKey prompts the user to enter an API key (or base URL for Ollama).
|
||||
// For OpenAI (Compatible) provider, also prompts for an optional base URL.
|
||||
func PromptForAPIKey(provider cline.ApiProvider) (string, string, error) {
|
||||
var apiKey string
|
||||
config := GetBYOAPIKeyFieldConfig(provider)
|
||||
|
||||
apiKeyField := huh.NewInput().
|
||||
Title(config.Title).
|
||||
EchoMode(config.EchoMode).
|
||||
Value(&apiKey)
|
||||
|
||||
if config.IsRequired {
|
||||
apiKeyField = apiKeyField.Validate(func(s string) error {
|
||||
if s == "" {
|
||||
return fmt.Errorf("API key cannot be empty")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
form := huh.NewForm(huh.NewGroup(apiKeyField))
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return "", "", fmt.Errorf("failed to get API key: %w", err)
|
||||
}
|
||||
|
||||
// For OpenAI (Compatible) provider, prompt for base URL
|
||||
if provider == cline.ApiProvider_OPENAI {
|
||||
var baseURL string
|
||||
baseURLForm := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("Base URL (optional, for OpenAI-compatible providers)").
|
||||
Placeholder("e.g., https://api.example.com/v1").
|
||||
Value(&baseURL).
|
||||
Description("Press Enter to skip if using standard OpenAI API"),
|
||||
),
|
||||
)
|
||||
|
||||
if err := baseURLForm.Run(); err != nil {
|
||||
return "", "", fmt.Errorf("failed to get base URL: %w", err)
|
||||
}
|
||||
|
||||
return apiKey, baseURL, nil
|
||||
}
|
||||
|
||||
return apiKey, "", nil
|
||||
}
|
||||
@@ -1,517 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/task"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
// ProviderDisplay represents a configured provider for display purposes
|
||||
type ProviderDisplay struct {
|
||||
Mode string // "Plan" or "Act"
|
||||
Provider cline.ApiProvider // Provider enum
|
||||
ModelID string // Model identifier
|
||||
HasAPIKey bool // Whether an API key is configured (never show actual key)
|
||||
BaseURL string // Base URL for providers like Ollama (can be shown publicly)
|
||||
}
|
||||
|
||||
// ProviderListResult holds the parsed provider configuration from state
|
||||
type ProviderListResult struct {
|
||||
PlanProvider *ProviderDisplay
|
||||
ActProvider *ProviderDisplay
|
||||
apiConfig map[string]interface{} // Store the raw apiConfig for scanning all providers
|
||||
}
|
||||
|
||||
// GetProviderConfigurations retrieves and parses provider configurations from Cline Core state
|
||||
func GetProviderConfigurations(ctx context.Context, manager *task.Manager) (*ProviderListResult, error) {
|
||||
if global.Config.Verbose {
|
||||
fmt.Println("[DEBUG] Retrieving provider configurations from Cline Core")
|
||||
}
|
||||
|
||||
// Get latest state from Cline Core
|
||||
state, err := manager.GetClient().State.GetLatestState(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get state: %w", err)
|
||||
}
|
||||
|
||||
stateJSON := state.StateJson
|
||||
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("[DEBUG] Retrieved state, parsing JSON (length: %d)\n", len(stateJSON))
|
||||
}
|
||||
|
||||
// Parse state_json as map[string]interface{}
|
||||
var stateData map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(stateJSON), &stateData); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse state JSON: %w", err)
|
||||
}
|
||||
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("[DEBUG] Parsed state data with %d keys\n", len(stateData))
|
||||
}
|
||||
|
||||
// Extract apiConfiguration object from state
|
||||
apiConfig, ok := stateData["apiConfiguration"].(map[string]interface{})
|
||||
if !ok {
|
||||
if global.Config.Verbose {
|
||||
fmt.Println("[DEBUG] No apiConfiguration found in state")
|
||||
}
|
||||
return &ProviderListResult{
|
||||
apiConfig: make(map[string]interface{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("[DEBUG] Found apiConfiguration with %d keys\n", len(apiConfig))
|
||||
}
|
||||
|
||||
// Extract plan mode configuration
|
||||
planProvider := extractProviderFromState(apiConfig, "plan")
|
||||
if global.Config.Verbose && planProvider != nil {
|
||||
fmt.Printf("[DEBUG] Plan mode: provider=%v, model=%s\n", planProvider.Provider, planProvider.ModelID)
|
||||
}
|
||||
|
||||
// Extract act mode configuration
|
||||
actProvider := extractProviderFromState(apiConfig, "act")
|
||||
if global.Config.Verbose && actProvider != nil {
|
||||
fmt.Printf("[DEBUG] Act mode: provider=%v, model=%s\n", actProvider.Provider, actProvider.ModelID)
|
||||
}
|
||||
|
||||
return &ProviderListResult{
|
||||
PlanProvider: planProvider,
|
||||
ActProvider: actProvider,
|
||||
apiConfig: apiConfig,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetAllReadyProviders returns all providers that have both a model and API key configured
|
||||
func (r *ProviderListResult) GetAllReadyProviders() []*ProviderDisplay {
|
||||
if r.apiConfig == nil {
|
||||
return []*ProviderDisplay{}
|
||||
}
|
||||
|
||||
var readyProviders []*ProviderDisplay
|
||||
seenProviders := make(map[cline.ApiProvider]bool)
|
||||
|
||||
// Check all possible providers
|
||||
allProviders := []cline.ApiProvider{
|
||||
cline.ApiProvider_CLINE,
|
||||
cline.ApiProvider_ANTHROPIC,
|
||||
cline.ApiProvider_OPENAI,
|
||||
cline.ApiProvider_OPENAI_NATIVE,
|
||||
cline.ApiProvider_OPENROUTER,
|
||||
cline.ApiProvider_XAI,
|
||||
cline.ApiProvider_BEDROCK,
|
||||
cline.ApiProvider_GEMINI,
|
||||
cline.ApiProvider_OLLAMA,
|
||||
cline.ApiProvider_CEREBRAS,
|
||||
cline.ApiProvider_NOUSRESEARCH,
|
||||
cline.ApiProvider_OCA,
|
||||
cline.ApiProvider_HICAP,
|
||||
}
|
||||
|
||||
// Check each provider to see if it's ready to use
|
||||
// We use "plan" mode to check, since both plan and act should have the same providers configured
|
||||
for _, provider := range allProviders {
|
||||
// Skip if we've already seen this provider
|
||||
if seenProviders[provider] {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this provider has a model configured
|
||||
modelID := getProviderSpecificModelID(r.apiConfig, "plan", provider)
|
||||
|
||||
// Determine if credentials exist
|
||||
hasCreds := checkAPIKeyExists(r.apiConfig, provider)
|
||||
|
||||
// Determine readiness: OCA uses auth state presence; others need creds and model
|
||||
if provider == cline.ApiProvider_OCA {
|
||||
state, _ := GetLatestOCAState(context.Background(), 2 *time.Second)
|
||||
if state == nil || state.User == nil {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
// Provider is not ready unless it has credentials AND a model configured
|
||||
if !hasCreds || modelID == "" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Get base URL for Ollama
|
||||
baseURL := ""
|
||||
if provider == cline.ApiProvider_OLLAMA {
|
||||
if url, ok := r.apiConfig["ollamaBaseUrl"].(string); ok {
|
||||
baseURL = url
|
||||
}
|
||||
}
|
||||
|
||||
// This provider is ready to use
|
||||
readyProviders = append(readyProviders, &ProviderDisplay{
|
||||
Mode: "Ready",
|
||||
Provider: provider,
|
||||
ModelID: modelID,
|
||||
HasAPIKey: checkAPIKeyExists(r.apiConfig, provider),
|
||||
BaseURL: baseURL,
|
||||
})
|
||||
seenProviders[provider] = true
|
||||
}
|
||||
|
||||
return readyProviders
|
||||
}
|
||||
|
||||
// extractProviderFromState extracts provider configuration for specific plan/act mode
|
||||
func extractProviderFromState(stateData map[string]interface{}, mode string) *ProviderDisplay {
|
||||
// Build key names based on mode
|
||||
providerKey := mode + "ModeApiProvider"
|
||||
|
||||
// Extract provider string from state
|
||||
providerStr, ok := stateData[providerKey].(string)
|
||||
if !ok || providerStr == "" {
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("[DEBUG] No provider configured for %s mode\n", mode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Map provider string to enum
|
||||
provider, ok := mapProviderStringToEnum(providerStr)
|
||||
if !ok {
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("[DEBUG] Unknown provider type: %s\n", providerStr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get provider-specific model ID
|
||||
modelID := getProviderSpecificModelID(stateData, mode, provider)
|
||||
|
||||
// Check if API key exists
|
||||
hasAPIKey := checkAPIKeyExists(stateData, provider)
|
||||
|
||||
// Get base URL for Ollama (can be shown publicly)
|
||||
baseURL := ""
|
||||
if provider == cline.ApiProvider_OLLAMA {
|
||||
if url, ok := stateData["ollamaBaseUrl"].(string); ok {
|
||||
baseURL = url
|
||||
}
|
||||
}
|
||||
|
||||
return &ProviderDisplay{
|
||||
Mode: capitalizeMode(mode),
|
||||
Provider: provider,
|
||||
ModelID: modelID,
|
||||
HasAPIKey: hasAPIKey,
|
||||
BaseURL: baseURL,
|
||||
}
|
||||
}
|
||||
|
||||
// mapProviderStringToEnum converts provider string from state to ApiProvider enum
|
||||
// Returns (provider, ok) where ok is false if the provider is unknown
|
||||
func mapProviderStringToEnum(providerStr string) (cline.ApiProvider, bool) {
|
||||
normalizedStr := strings.ToLower(providerStr)
|
||||
|
||||
// Map string values to enum values
|
||||
switch normalizedStr {
|
||||
case "anthropic":
|
||||
return cline.ApiProvider_ANTHROPIC, true
|
||||
case "openai", "openai-compatible": // internal name is 'openai', but this is actually the openai-compatible provider
|
||||
return cline.ApiProvider_OPENAI, true
|
||||
case "openai-native": // This is the native, official Open AI provider
|
||||
return cline.ApiProvider_OPENAI_NATIVE, true
|
||||
case "openrouter":
|
||||
return cline.ApiProvider_OPENROUTER, true
|
||||
case "xai":
|
||||
return cline.ApiProvider_XAI, true
|
||||
case "bedrock":
|
||||
return cline.ApiProvider_BEDROCK, true
|
||||
case "gemini":
|
||||
return cline.ApiProvider_GEMINI, true
|
||||
case "ollama":
|
||||
return cline.ApiProvider_OLLAMA, true
|
||||
case "cerebras":
|
||||
return cline.ApiProvider_CEREBRAS, true
|
||||
case "cline":
|
||||
return cline.ApiProvider_CLINE, true
|
||||
case "oca":
|
||||
return cline.ApiProvider_OCA, true
|
||||
case "hicap":
|
||||
return cline.ApiProvider_HICAP, true
|
||||
case "nousResearch":
|
||||
return cline.ApiProvider_NOUSRESEARCH, true
|
||||
default:
|
||||
return cline.ApiProvider_ANTHROPIC, false // Return 0 value with false
|
||||
}
|
||||
}
|
||||
|
||||
// GetProviderIDForEnum converts a provider enum to the provider ID string
|
||||
// This is the inverse of mapProviderStringToEnum and is used for provider definitions
|
||||
func GetProviderIDForEnum(provider cline.ApiProvider) string {
|
||||
switch provider {
|
||||
case cline.ApiProvider_ANTHROPIC:
|
||||
return "anthropic"
|
||||
case cline.ApiProvider_OPENAI:
|
||||
return "openai-compatible"
|
||||
case cline.ApiProvider_OPENAI_NATIVE:
|
||||
return "openai-native"
|
||||
case cline.ApiProvider_OPENROUTER:
|
||||
return "openrouter"
|
||||
case cline.ApiProvider_XAI:
|
||||
return "xai"
|
||||
case cline.ApiProvider_BEDROCK:
|
||||
return "bedrock"
|
||||
case cline.ApiProvider_GEMINI:
|
||||
return "gemini"
|
||||
case cline.ApiProvider_OLLAMA:
|
||||
return "ollama"
|
||||
case cline.ApiProvider_CEREBRAS:
|
||||
return "cerebras"
|
||||
case cline.ApiProvider_CLINE:
|
||||
return "cline"
|
||||
case cline.ApiProvider_OCA:
|
||||
return "oca"
|
||||
case cline.ApiProvider_HICAP:
|
||||
return "hicap"
|
||||
case cline.ApiProvider_NOUSRESEARCH:
|
||||
return "nousResearch"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// getProviderSpecificModelID gets the provider-specific model ID field from state
|
||||
func getProviderSpecificModelID(stateData map[string]interface{}, mode string, provider cline.ApiProvider) string {
|
||||
modelKey, err := GetModelIDFieldName(provider, mode)
|
||||
if err != nil {
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("[DEBUG] Error getting model ID field name: %v\n", err)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("[DEBUG] Looking for model ID in key: %s\n", modelKey)
|
||||
}
|
||||
|
||||
// Extract model ID from state
|
||||
modelID, _ := stateData[modelKey].(string)
|
||||
return modelID
|
||||
}
|
||||
|
||||
// checkAPIKeyExists checks if API key field exists in state (never retrieve actual key)
|
||||
func checkAPIKeyExists(stateData map[string]interface{}, provider cline.ApiProvider) bool {
|
||||
// Get field mapping from centralized function
|
||||
fields, err := GetProviderFields(provider)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
keyField := fields.APIKeyField
|
||||
|
||||
// Check if the key exists and is not empty
|
||||
if value, ok := stateData[keyField]; ok {
|
||||
if str, ok := value.(string); ok && str != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// capitalizeMode capitalizes the mode string for display
|
||||
func capitalizeMode(mode string) string {
|
||||
if len(mode) == 0 {
|
||||
return mode
|
||||
}
|
||||
return strings.ToUpper(mode[:1]) + mode[1:]
|
||||
}
|
||||
|
||||
// GetProviderDisplayName returns a user-friendly name for the provider
|
||||
func GetProviderDisplayName(provider cline.ApiProvider) string {
|
||||
switch provider {
|
||||
case cline.ApiProvider_ANTHROPIC:
|
||||
return "Anthropic"
|
||||
case cline.ApiProvider_OPENAI:
|
||||
return "OpenAI Compatible"
|
||||
case cline.ApiProvider_OPENAI_NATIVE:
|
||||
return "OpenAI (Official)"
|
||||
case cline.ApiProvider_OPENROUTER:
|
||||
return "OpenRouter"
|
||||
case cline.ApiProvider_XAI:
|
||||
return "X AI (Grok)"
|
||||
case cline.ApiProvider_BEDROCK:
|
||||
return "AWS Bedrock"
|
||||
case cline.ApiProvider_GEMINI:
|
||||
return "Google Gemini"
|
||||
case cline.ApiProvider_OLLAMA:
|
||||
return "Ollama"
|
||||
case cline.ApiProvider_CEREBRAS:
|
||||
return "Cerebras"
|
||||
case cline.ApiProvider_CLINE:
|
||||
return "Cline (Official)"
|
||||
case cline.ApiProvider_OCA:
|
||||
return "Oracle Code Assist"
|
||||
case cline.ApiProvider_HICAP:
|
||||
return "Hicap"
|
||||
case cline.ApiProvider_NOUSRESEARCH:
|
||||
return "NousResearch"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// FormatProviderList formats the complete provider list for console display
|
||||
// This now shows ALL providers that have both a model and API key configured
|
||||
func FormatProviderList(result *ProviderListResult) string {
|
||||
var output strings.Builder
|
||||
|
||||
output.WriteString("\n=== Configured API Providers ===\n\n")
|
||||
|
||||
// Get the currently active provider
|
||||
var activeProvider cline.ApiProvider
|
||||
var activeProviderSet bool
|
||||
if result.ActProvider != nil {
|
||||
activeProvider = result.ActProvider.Provider
|
||||
activeProviderSet = true
|
||||
}
|
||||
|
||||
// Get all ready-to-use providers (those with both API key and model configured)
|
||||
readyProviders := result.GetAllReadyProviders()
|
||||
|
||||
if len(readyProviders) == 0 {
|
||||
output.WriteString(" No providers ready to use.\n")
|
||||
output.WriteString(" A provider is ready when it has both a model and API key configured.\n")
|
||||
output.WriteString(" Use 'Configure a new provider' to configure one.\n\n")
|
||||
} else {
|
||||
//output.WriteString(fmt.Sprintf(" %d provider(s) ready to use:\n\n", len(readyProviders)))
|
||||
|
||||
for _, display := range readyProviders {
|
||||
// Check if this is the active provider
|
||||
isActive := activeProviderSet && display.Provider == activeProvider
|
||||
|
||||
if isActive {
|
||||
output.WriteString(fmt.Sprintf(" ✓ %s (ACTIVE)\n", GetProviderDisplayName(display.Provider)))
|
||||
} else {
|
||||
output.WriteString(fmt.Sprintf(" • %s\n", GetProviderDisplayName(display.Provider)))
|
||||
}
|
||||
|
||||
output.WriteString(fmt.Sprintf(" Model: %s\n", display.ModelID))
|
||||
|
||||
// Show status based on provider type
|
||||
if display.Provider == cline.ApiProvider_OLLAMA {
|
||||
if display.BaseURL != "" {
|
||||
output.WriteString(fmt.Sprintf(" Base URL: %s\n", display.BaseURL))
|
||||
} else {
|
||||
output.WriteString(" Base URL: (default)\n")
|
||||
}
|
||||
} else if display.Provider == cline.ApiProvider_CLINE || display.Provider == cline.ApiProvider_OCA {
|
||||
output.WriteString(" Status: Authenticated\n")
|
||||
} else {
|
||||
output.WriteString(" API Key: Configured\n")
|
||||
}
|
||||
|
||||
output.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
output.WriteString("================================\n")
|
||||
|
||||
return output.String()
|
||||
}
|
||||
|
||||
// DetectAllConfiguredProviders scans the state to find all providers that have API keys configured.
|
||||
// This allows switching between multiple providers even when only one is currently active.
|
||||
func DetectAllConfiguredProviders(ctx context.Context, manager *task.Manager) ([]cline.ApiProvider, error) {
|
||||
verboseLog("[DEBUG] Detecting all configured providers...")
|
||||
|
||||
// Get latest state from Cline Core
|
||||
state, err := manager.GetClient().State.GetLatestState(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get state: %w", err)
|
||||
}
|
||||
|
||||
stateJSON := state.StateJson
|
||||
|
||||
// Parse state_json as map[string]interface{}
|
||||
var stateData map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(stateJSON), &stateData); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse state JSON: %w", err)
|
||||
}
|
||||
|
||||
// Extract apiConfiguration object from state
|
||||
apiConfig, ok := stateData["apiConfiguration"].(map[string]interface{})
|
||||
if !ok {
|
||||
verboseLog("[DEBUG] No apiConfiguration found in state")
|
||||
verboseLog("[DEBUG] Available keys in stateData: %v", getMapKeys(stateData))
|
||||
return []cline.ApiProvider{}, nil
|
||||
}
|
||||
|
||||
verboseLog("[DEBUG] apiConfiguration keys: %v", getMapKeys(apiConfig))
|
||||
|
||||
var configuredProviders []cline.ApiProvider
|
||||
|
||||
// Check for Cline provider (uses authentication instead of API key)
|
||||
if IsAuthenticated(ctx) {
|
||||
configuredProviders = append(configuredProviders, cline.ApiProvider_CLINE)
|
||||
verboseLog("[DEBUG] Cline provider is authenticated")
|
||||
}
|
||||
|
||||
// Check OCA provider via global auth subscription (state presence)
|
||||
if state, _ := GetLatestOCAState(context.Background(), 2*time.Second); state != nil && state.User != nil {
|
||||
configuredProviders = append(configuredProviders, cline.ApiProvider_OCA)
|
||||
verboseLog("[DEBUG] OCA provider has active auth state")
|
||||
}
|
||||
|
||||
// Check each BYO provider for API key presence
|
||||
providersToCheck := []struct {
|
||||
provider cline.ApiProvider
|
||||
keyField string
|
||||
}{
|
||||
{cline.ApiProvider_ANTHROPIC, "apiKey"},
|
||||
{cline.ApiProvider_OPENAI, "openAiApiKey"},
|
||||
{cline.ApiProvider_OPENAI_NATIVE, "openAiNativeApiKey"},
|
||||
{cline.ApiProvider_OPENROUTER, "openRouterApiKey"},
|
||||
{cline.ApiProvider_XAI, "xaiApiKey"},
|
||||
{cline.ApiProvider_BEDROCK, "awsAccessKey"},
|
||||
{cline.ApiProvider_GEMINI, "geminiApiKey"},
|
||||
{cline.ApiProvider_OLLAMA, "ollamaBaseUrl"}, // Ollama uses baseUrl instead of API key
|
||||
{cline.ApiProvider_CEREBRAS, "cerebrasApiKey"},
|
||||
{cline.ApiProvider_HICAP, "hicapApiKey"},
|
||||
{cline.ApiProvider_NOUSRESEARCH, "nousResearchApiKey"},
|
||||
}
|
||||
|
||||
for _, providerCheck := range providersToCheck {
|
||||
verboseLog("[DEBUG] Checking for %s key: %s", GetProviderDisplayName(providerCheck.provider), providerCheck.keyField)
|
||||
if value, ok := apiConfig[providerCheck.keyField]; ok {
|
||||
verboseLog("[DEBUG] Found key, value type: %T, is empty: %v", value, value == "")
|
||||
if str, ok := value.(string); ok && str != "" {
|
||||
configuredProviders = append(configuredProviders, providerCheck.provider)
|
||||
verboseLog("[DEBUG] ✓ Provider %s is configured", GetProviderDisplayName(providerCheck.provider))
|
||||
}
|
||||
} else {
|
||||
verboseLog("[DEBUG] Key %s not found", providerCheck.keyField)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
verboseLog("[DEBUG] Total configured providers: %d", len(configuredProviders))
|
||||
for _, p := range configuredProviders {
|
||||
verboseLog("[DEBUG] - %s", GetProviderDisplayName(p))
|
||||
}
|
||||
|
||||
return configuredProviders, nil
|
||||
}
|
||||
|
||||
// getMapKeys returns the keys of a map for debugging
|
||||
func getMapKeys(m map[string]interface{}) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
@@ -1,647 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/task"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/types/known/fieldmaskpb"
|
||||
)
|
||||
|
||||
// updateApiConfigurationPartial is a helper that calls the gRPC method with optional verbose logging.
|
||||
// This replaces the Manager.updateApiConfigurationPartial method to keep auth-specific code in the auth package.
|
||||
func updateApiConfigurationPartial(ctx context.Context, manager *task.Manager, request *cline.UpdateApiConfigurationPartialRequest) error {
|
||||
if global.Config.Verbose {
|
||||
fmt.Println("[DEBUG] Updating API configuration (partial)")
|
||||
if request.UpdateMask != nil && len(request.UpdateMask.Paths) > 0 {
|
||||
fmt.Printf("[DEBUG] Field mask paths: %v\n", request.UpdateMask.Paths)
|
||||
}
|
||||
if request.ApiConfiguration != nil {
|
||||
apiConfig := request.ApiConfiguration
|
||||
if apiConfig.PlanModeApiProvider != nil {
|
||||
fmt.Printf("[DEBUG] Plan mode provider: %s\n", *apiConfig.PlanModeApiProvider)
|
||||
}
|
||||
if apiConfig.ActModeApiProvider != nil {
|
||||
fmt.Printf("[DEBUG] Act mode provider: %s\n", *apiConfig.ActModeApiProvider)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Call the Models service to update API configuration
|
||||
_, err := manager.GetClient().Models.UpdateApiConfigurationPartial(ctx, request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update API configuration (partial): %w", err)
|
||||
}
|
||||
|
||||
if global.Config.Verbose {
|
||||
fmt.Println("[DEBUG] API configuration updated successfully (partial)")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProviderFields defines all the field names associated with a specific provider
|
||||
type ProviderFields struct {
|
||||
APIKeyField string // API key field name (e.g., "apiKey", "openAiApiKey")
|
||||
BaseURLField string // Base URL field name (optional, empty if not applicable)
|
||||
PlanModeModelIDField string // Plan mode model ID field (e.g., "planModeApiModelId")
|
||||
ActModeModelIDField string // Act mode model ID field (e.g., "actModeApiModelId")
|
||||
PlanModeModelInfoField string // Plan mode model info field (optional, empty if not applicable)
|
||||
ActModeModelInfoField string // Act mode model info field (optional, empty if not applicable)
|
||||
// Provider-specific additional model ID fields
|
||||
PlanModeProviderSpecificModelIDField string // e.g., "planModeOpenRouterModelId"
|
||||
ActModeProviderSpecificModelIDField string // e.g., "actModeOpenRouterModelId"
|
||||
}
|
||||
|
||||
// GetProviderFields returns the field mapping for a given provider
|
||||
func GetProviderFields(provider cline.ApiProvider) (ProviderFields, error) {
|
||||
switch provider {
|
||||
case cline.ApiProvider_ANTHROPIC:
|
||||
return ProviderFields{
|
||||
APIKeyField: "apiKey",
|
||||
PlanModeModelIDField: "planModeApiModelId",
|
||||
ActModeModelIDField: "actModeApiModelId",
|
||||
}, nil
|
||||
|
||||
case cline.ApiProvider_OPENAI:
|
||||
return ProviderFields{
|
||||
APIKeyField: "openAiApiKey",
|
||||
BaseURLField: "openAiBaseUrl",
|
||||
PlanModeModelIDField: "planModeApiModelId",
|
||||
ActModeModelIDField: "actModeApiModelId",
|
||||
PlanModeProviderSpecificModelIDField: "planModeOpenAiModelId",
|
||||
ActModeProviderSpecificModelIDField: "actModeOpenAiModelId",
|
||||
}, nil
|
||||
|
||||
case cline.ApiProvider_OPENROUTER:
|
||||
return ProviderFields{
|
||||
APIKeyField: "openRouterApiKey",
|
||||
PlanModeModelIDField: "planModeApiModelId",
|
||||
ActModeModelIDField: "actModeApiModelId",
|
||||
PlanModeModelInfoField: "planModeOpenRouterModelInfo",
|
||||
ActModeModelInfoField: "actModeOpenRouterModelInfo",
|
||||
PlanModeProviderSpecificModelIDField: "planModeOpenRouterModelId",
|
||||
ActModeProviderSpecificModelIDField: "actModeOpenRouterModelId",
|
||||
}, nil
|
||||
|
||||
case cline.ApiProvider_XAI:
|
||||
return ProviderFields{
|
||||
APIKeyField: "xaiApiKey",
|
||||
PlanModeModelIDField: "planModeApiModelId",
|
||||
ActModeModelIDField: "actModeApiModelId",
|
||||
}, nil
|
||||
|
||||
case cline.ApiProvider_BEDROCK:
|
||||
return ProviderFields{
|
||||
APIKeyField: "awsAccessKey",
|
||||
PlanModeModelIDField: "planModeApiModelId",
|
||||
ActModeModelIDField: "actModeApiModelId",
|
||||
PlanModeProviderSpecificModelIDField: "planModeAwsBedrockCustomModelBaseId",
|
||||
ActModeProviderSpecificModelIDField: "actModeAwsBedrockCustomModelBaseId",
|
||||
}, nil
|
||||
|
||||
case cline.ApiProvider_GEMINI:
|
||||
return ProviderFields{
|
||||
APIKeyField: "geminiApiKey",
|
||||
PlanModeModelIDField: "planModeApiModelId",
|
||||
ActModeModelIDField: "actModeApiModelId",
|
||||
}, nil
|
||||
|
||||
case cline.ApiProvider_OPENAI_NATIVE:
|
||||
return ProviderFields{
|
||||
APIKeyField: "openAiNativeApiKey",
|
||||
PlanModeModelIDField: "planModeApiModelId",
|
||||
ActModeModelIDField: "actModeApiModelId",
|
||||
}, nil
|
||||
|
||||
case cline.ApiProvider_OLLAMA:
|
||||
return ProviderFields{
|
||||
APIKeyField: "ollamaBaseUrl",
|
||||
PlanModeModelIDField: "planModeApiModelId",
|
||||
ActModeModelIDField: "actModeApiModelId",
|
||||
PlanModeProviderSpecificModelIDField: "planModeOllamaModelId",
|
||||
ActModeProviderSpecificModelIDField: "actModeOllamaModelId",
|
||||
}, nil
|
||||
|
||||
case cline.ApiProvider_CEREBRAS:
|
||||
return ProviderFields{
|
||||
APIKeyField: "cerebrasApiKey",
|
||||
PlanModeModelIDField: "planModeApiModelId",
|
||||
ActModeModelIDField: "actModeApiModelId",
|
||||
}, nil
|
||||
|
||||
case cline.ApiProvider_CLINE:
|
||||
return ProviderFields{
|
||||
APIKeyField: "clineApiKey",
|
||||
PlanModeModelIDField: "planModeApiModelId",
|
||||
ActModeModelIDField: "actModeApiModelId",
|
||||
PlanModeModelInfoField: "planModeOpenRouterModelInfo",
|
||||
ActModeModelInfoField: "actModeOpenRouterModelInfo",
|
||||
PlanModeProviderSpecificModelIDField: "planModeOpenRouterModelId",
|
||||
ActModeProviderSpecificModelIDField: "actModeOpenRouterModelId",
|
||||
}, nil
|
||||
|
||||
case cline.ApiProvider_OCA:
|
||||
return ProviderFields{
|
||||
APIKeyField: "ocaApiKey",
|
||||
PlanModeModelIDField: "planModeApiModelId",
|
||||
ActModeModelIDField: "actModeApiModelId",
|
||||
PlanModeModelInfoField: "planModeOcaModelInfo",
|
||||
ActModeModelInfoField: "actModeOcaModelInfo",
|
||||
PlanModeProviderSpecificModelIDField: "planModeOcaModelId",
|
||||
ActModeProviderSpecificModelIDField: "actModeOcaModelId",
|
||||
}, nil
|
||||
case cline.ApiProvider_HICAP:
|
||||
return ProviderFields{
|
||||
APIKeyField: "hicapApiKey",
|
||||
PlanModeModelInfoField: "planModeHicapModelInfo",
|
||||
ActModeModelInfoField: "actModeHicapModelInfo",
|
||||
PlanModeProviderSpecificModelIDField: "planModeHicapModelId",
|
||||
ActModeProviderSpecificModelIDField: "actModeHicapModelId",
|
||||
}, nil
|
||||
|
||||
case cline.ApiProvider_NOUSRESEARCH:
|
||||
return ProviderFields{
|
||||
APIKeyField: "nousResearchApiKey",
|
||||
PlanModeModelIDField: "planModeApiModelId",
|
||||
ActModeModelIDField: "actModeApiModelId",
|
||||
PlanModeProviderSpecificModelIDField: "planModeNousResearchModelId",
|
||||
ActModeProviderSpecificModelIDField: "actModeNousResearchModelId",
|
||||
}, nil
|
||||
|
||||
default:
|
||||
return ProviderFields{}, fmt.Errorf("unsupported provider: %v", provider)
|
||||
}
|
||||
}
|
||||
|
||||
// ProviderUpdatesPartial defines optional fields for partial provider updates
|
||||
// Uses pointers to distinguish between "not provided" and "set to empty"
|
||||
type ProviderUpdatesPartial struct {
|
||||
ModelID *string // New model ID (optional)
|
||||
APIKey *string // New API key (optional)
|
||||
ModelInfo interface{} // New model info (optional, provider-specific)
|
||||
BaseURL *string // New base URL (optional, e.g., for OCA, Ollama)
|
||||
RefreshToken *string // New refresh token (optional, e.g., for OCA)
|
||||
Mode *string // New mode (optional, e.g., "internal" or "external" for OCA)
|
||||
}
|
||||
|
||||
// GetModelIDFieldName returns the appropriate model ID field name for a provider and mode.
|
||||
// This helper centralizes the logic for determining whether to use provider-specific
|
||||
// or generic model ID fields.
|
||||
func GetModelIDFieldName(provider cline.ApiProvider, mode string) (string, error) {
|
||||
fields, err := GetProviderFields(provider)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if mode == "plan" {
|
||||
// Use provider-specific field if available, otherwise use generic field
|
||||
if fields.PlanModeProviderSpecificModelIDField != "" {
|
||||
return fields.PlanModeProviderSpecificModelIDField, nil
|
||||
}
|
||||
return fields.PlanModeModelIDField, nil
|
||||
}
|
||||
|
||||
// Act mode
|
||||
if fields.ActModeProviderSpecificModelIDField != "" {
|
||||
return fields.ActModeProviderSpecificModelIDField, nil
|
||||
}
|
||||
return fields.ActModeModelIDField, nil
|
||||
}
|
||||
|
||||
// buildProviderFieldMask builds a list of camelCase field paths for the field mask.
|
||||
// When includeProviderEnums is true, the provider enum fields are included (for setting active provider).
|
||||
// When false, only the data fields are included (for configuring without activating).
|
||||
func buildProviderFieldMask(fields ProviderFields, includeAPIKey bool, includeModelID bool, includeModelInfo bool, includeBaseURL bool, includeProviderEnums bool) []string {
|
||||
var fieldPaths []string
|
||||
|
||||
// Include provider enums if requested (used when setting active provider)
|
||||
if includeProviderEnums {
|
||||
fieldPaths = append(fieldPaths, "planModeApiProvider", "actModeApiProvider")
|
||||
}
|
||||
|
||||
// Add API key field if requested
|
||||
if includeAPIKey {
|
||||
fieldPaths = append(fieldPaths, fields.APIKeyField)
|
||||
// Special case: Bedrock also needs secret key
|
||||
if fields.APIKeyField == "awsAccessKey" {
|
||||
fieldPaths = append(fieldPaths, "awsSecretKey")
|
||||
}
|
||||
}
|
||||
|
||||
// Add base URL field if requested and applicable
|
||||
if includeBaseURL && fields.BaseURLField != "" {
|
||||
fieldPaths = append(fieldPaths, fields.BaseURLField)
|
||||
}
|
||||
|
||||
// Add model ID fields if requested
|
||||
if includeModelID {
|
||||
// Only include provider-specific fields if they exist, otherwise use generic fields
|
||||
if fields.PlanModeProviderSpecificModelIDField != "" {
|
||||
// Provider has specific fields - use ONLY those
|
||||
fieldPaths = append(fieldPaths, fields.PlanModeProviderSpecificModelIDField)
|
||||
fieldPaths = append(fieldPaths, fields.ActModeProviderSpecificModelIDField)
|
||||
} else {
|
||||
// Provider uses generic fields - update those
|
||||
fieldPaths = append(fieldPaths, fields.PlanModeModelIDField)
|
||||
fieldPaths = append(fieldPaths, fields.ActModeModelIDField)
|
||||
}
|
||||
}
|
||||
|
||||
// Add model info fields if requested and applicable
|
||||
if includeModelInfo && fields.PlanModeModelInfoField != "" {
|
||||
fieldPaths = append(fieldPaths, fields.PlanModeModelInfoField)
|
||||
fieldPaths = append(fieldPaths, fields.ActModeModelInfoField)
|
||||
}
|
||||
|
||||
return fieldPaths
|
||||
}
|
||||
|
||||
// setAPIKeyField sets the appropriate API key field in the config based on the field name
|
||||
func setAPIKeyField(apiConfig *cline.ModelsApiConfiguration, fieldName string, value *string) {
|
||||
switch fieldName {
|
||||
case "apiKey":
|
||||
apiConfig.ApiKey = value
|
||||
case "openAiApiKey":
|
||||
apiConfig.OpenAiApiKey = value
|
||||
case "openAiNativeApiKey":
|
||||
apiConfig.OpenAiNativeApiKey = value
|
||||
case "openRouterApiKey":
|
||||
apiConfig.OpenRouterApiKey = value
|
||||
case "xaiApiKey":
|
||||
apiConfig.XaiApiKey = value
|
||||
case "awsAccessKey":
|
||||
apiConfig.AwsAccessKey = value
|
||||
case "geminiApiKey":
|
||||
apiConfig.GeminiApiKey = value
|
||||
case "ollamaBaseUrl":
|
||||
apiConfig.OllamaBaseUrl = value
|
||||
case "cerebrasApiKey":
|
||||
apiConfig.CerebrasApiKey = value
|
||||
case "clineApiKey":
|
||||
apiConfig.ClineApiKey = value
|
||||
case "ocaApiKey":
|
||||
apiConfig.OcaApiKey = value
|
||||
case "hicapApiKey":
|
||||
apiConfig.HicapApiKey = value
|
||||
case "nousResearchApiKey":
|
||||
apiConfig.NousResearchApiKey = value
|
||||
}
|
||||
}
|
||||
|
||||
// setProviderSpecificModelID sets the appropriate provider-specific model ID fields when possible
|
||||
func setProviderSpecificModelID(apiConfig *cline.ModelsApiConfiguration, fieldName string, value *string) {
|
||||
switch fieldName {
|
||||
case "planModeOpenAiModelId":
|
||||
apiConfig.PlanModeOpenAiModelId = value
|
||||
apiConfig.ActModeOpenAiModelId = value
|
||||
case "planModeOpenRouterModelId":
|
||||
apiConfig.PlanModeOpenRouterModelId = value
|
||||
apiConfig.ActModeOpenRouterModelId = value
|
||||
case "planModeOllamaModelId":
|
||||
apiConfig.PlanModeOllamaModelId = value
|
||||
apiConfig.ActModeOllamaModelId = value
|
||||
case "planModeAwsBedrockCustomModelBaseId":
|
||||
apiConfig.PlanModeAwsBedrockCustomModelBaseId = value
|
||||
apiConfig.ActModeAwsBedrockCustomModelBaseId = value
|
||||
case "planModeOcaModelId":
|
||||
apiConfig.PlanModeOcaModelId = value
|
||||
apiConfig.ActModeOcaModelId = value
|
||||
case "planModeHicapModelId":
|
||||
apiConfig.PlanModeHicapModelId = value
|
||||
apiConfig.ActModeHicapModelId = value
|
||||
case "planModeNousResearchModelId":
|
||||
apiConfig.PlanModeNousResearchModelId = value
|
||||
apiConfig.ActModeNousResearchModelId = value
|
||||
}
|
||||
}
|
||||
|
||||
// AddProviderPartial configures a new provider with all necessary fields using partial updates.
|
||||
func AddProviderPartial(ctx context.Context, manager *task.Manager, provider cline.ApiProvider, modelID string, apiKey string, baseURL string, modelInfo interface{}) error {
|
||||
// Get field mapping for this provider
|
||||
fields, err := GetProviderFields(provider)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Build a ModelsApiConfiguration with only the relevant provider fields set
|
||||
apiConfig := &cline.ModelsApiConfiguration{}
|
||||
|
||||
// Set API key field
|
||||
if apiKey != "" || fields.APIKeyField != "ollamaBaseUrl" {
|
||||
setAPIKeyField(apiConfig, fields.APIKeyField, proto.String(apiKey))
|
||||
}
|
||||
|
||||
// Set base URL field if provided and applicable
|
||||
includeBaseURL := false
|
||||
if baseURL != "" && fields.BaseURLField != "" {
|
||||
setBaseURLField(apiConfig, fields.BaseURLField, proto.String(baseURL))
|
||||
includeBaseURL = true
|
||||
}
|
||||
|
||||
// Set model ID fields
|
||||
apiConfig.PlanModeApiModelId = proto.String(modelID)
|
||||
apiConfig.ActModeApiModelId = proto.String(modelID)
|
||||
|
||||
// Set provider-specific model ID fields if applicable
|
||||
if fields.PlanModeProviderSpecificModelIDField != "" {
|
||||
setProviderSpecificModelID(apiConfig, fields.PlanModeProviderSpecificModelIDField, proto.String(modelID))
|
||||
}
|
||||
|
||||
// Set model info if applicable and provided
|
||||
if fields.PlanModeModelInfoField != "" && modelInfo != nil {
|
||||
if openRouterInfo, ok := modelInfo.(*cline.OpenRouterModelInfo); ok {
|
||||
apiConfig.PlanModeOpenRouterModelInfo = openRouterInfo
|
||||
apiConfig.ActModeOpenRouterModelInfo = openRouterInfo
|
||||
}
|
||||
}
|
||||
|
||||
// Build field mask including all fields we're setting (without provider enums)
|
||||
includeModelInfo := fields.PlanModeModelInfoField != "" && modelInfo != nil
|
||||
fieldPaths := buildProviderFieldMask(fields, true, true, includeModelInfo, includeBaseURL, false)
|
||||
|
||||
// Create field mask
|
||||
fieldMask := &fieldmaskpb.FieldMask{Paths: fieldPaths}
|
||||
|
||||
// Apply the partial update
|
||||
request := &cline.UpdateApiConfigurationPartialRequest{
|
||||
ApiConfiguration: apiConfig,
|
||||
UpdateMask: fieldMask,
|
||||
}
|
||||
|
||||
if err := updateApiConfigurationPartial(ctx, manager, request); err != nil {
|
||||
return fmt.Errorf("failed to update API configuration: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateProviderPartial updates specific fields for an existing provider using partial updates.
|
||||
// If setAsActive is true, this will also set the provider as the active provider for both Plan and Act modes.
|
||||
func UpdateProviderPartial(ctx context.Context, manager *task.Manager, provider cline.ApiProvider, updates ProviderUpdatesPartial, setAsActive bool) error {
|
||||
// Get field mapping for this provider
|
||||
fields, err := GetProviderFields(provider)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Build a ModelsApiConfiguration with only the fields being updated
|
||||
apiConfig := &cline.ModelsApiConfiguration{}
|
||||
|
||||
// Set provider enum for BOTH Plan and Act modes if setAsActive is true
|
||||
if setAsActive {
|
||||
apiConfig.PlanModeApiProvider = &provider
|
||||
apiConfig.ActModeApiProvider = &provider
|
||||
}
|
||||
|
||||
// Track what we're updating for field mask
|
||||
includeAPIKey := updates.APIKey != nil
|
||||
includeModelID := updates.ModelID != nil
|
||||
includeModelInfo := updates.ModelInfo != nil && fields.PlanModeModelInfoField != ""
|
||||
|
||||
// Update API key if provided
|
||||
if updates.APIKey != nil {
|
||||
setAPIKeyField(apiConfig, fields.APIKeyField, updates.APIKey)
|
||||
}
|
||||
|
||||
// Update model ID if provided
|
||||
if updates.ModelID != nil {
|
||||
// Only set provider-specific fields if they exist, otherwise use generic fields
|
||||
if fields.PlanModeProviderSpecificModelIDField != "" {
|
||||
setProviderSpecificModelID(apiConfig, fields.PlanModeProviderSpecificModelIDField, updates.ModelID)
|
||||
} else {
|
||||
// Provider uses generic fields - set those
|
||||
apiConfig.PlanModeApiModelId = updates.ModelID
|
||||
apiConfig.ActModeApiModelId = updates.ModelID
|
||||
}
|
||||
}
|
||||
|
||||
// Update model info if provided
|
||||
if updates.ModelInfo != nil && fields.PlanModeModelInfoField != "" {
|
||||
if openRouterInfo, ok := updates.ModelInfo.(*cline.OpenRouterModelInfo); ok {
|
||||
apiConfig.PlanModeOpenRouterModelInfo = openRouterInfo
|
||||
apiConfig.ActModeOpenRouterModelInfo = openRouterInfo
|
||||
}
|
||||
}
|
||||
|
||||
// Build field mask for only the fields being updated
|
||||
fieldPaths := buildProviderFieldMask(fields, includeAPIKey, includeModelID, includeModelInfo, false, setAsActive)
|
||||
|
||||
// Create field mask
|
||||
fieldMask := &fieldmaskpb.FieldMask{Paths: fieldPaths}
|
||||
|
||||
// Apply the partial update
|
||||
request := &cline.UpdateApiConfigurationPartialRequest{
|
||||
ApiConfiguration: apiConfig,
|
||||
UpdateMask: fieldMask,
|
||||
}
|
||||
|
||||
if err := updateApiConfigurationPartial(ctx, manager, request); err != nil {
|
||||
return fmt.Errorf("failed to update API configuration: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveProviderPartial removes a provider by clearing its API key using partial updates
|
||||
func RemoveProviderPartial(ctx context.Context, manager *task.Manager, provider cline.ApiProvider) error {
|
||||
// Get field mapping for this provider
|
||||
fields, err := GetProviderFields(provider)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Build an EMPTY ModelsApiConfiguration (or one with empty API key field)
|
||||
// Fields in the mask without values will be cleared
|
||||
apiConfig := &cline.ModelsApiConfiguration{}
|
||||
|
||||
// Build field mask with only the API key field(s)
|
||||
// For Bedrock, include both access key and secret key
|
||||
fieldPaths := []string{fields.APIKeyField}
|
||||
if provider == cline.ApiProvider_BEDROCK {
|
||||
fieldPaths = append(fieldPaths, "awsSecretKey")
|
||||
}
|
||||
|
||||
// Create field mask
|
||||
fieldMask := &fieldmaskpb.FieldMask{Paths: fieldPaths}
|
||||
|
||||
// Apply the partial update (clearing API key by including in mask without value)
|
||||
request := &cline.UpdateApiConfigurationPartialRequest{
|
||||
ApiConfiguration: apiConfig,
|
||||
UpdateMask: fieldMask,
|
||||
}
|
||||
|
||||
if err := updateApiConfigurationPartial(ctx, manager, request); err != nil {
|
||||
return fmt.Errorf("failed to update API configuration: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setBaseURLField sets the appropriate base URL field in the config based on the field name
|
||||
func setBaseURLField(apiConfig *cline.ModelsApiConfiguration, fieldName string, value *string) {
|
||||
switch fieldName {
|
||||
case "ocaBaseUrl":
|
||||
apiConfig.OcaBaseUrl = value
|
||||
case "ollamaBaseUrl":
|
||||
apiConfig.OllamaBaseUrl = value
|
||||
case "openAiBaseUrl":
|
||||
apiConfig.OpenAiBaseUrl = value
|
||||
case "geminiBaseUrl":
|
||||
apiConfig.GeminiBaseUrl = value
|
||||
case "liteLlmBaseUrl":
|
||||
apiConfig.LiteLlmBaseUrl = value
|
||||
case "anthropicBaseUrl":
|
||||
apiConfig.AnthropicBaseUrl = value
|
||||
case "requestyBaseUrl":
|
||||
apiConfig.RequestyBaseUrl = value
|
||||
case "lmStudioBaseUrl":
|
||||
apiConfig.LmStudioBaseUrl = value
|
||||
case "oca":
|
||||
apiConfig.OcaBaseUrl = value
|
||||
}
|
||||
}
|
||||
|
||||
// setRefreshTokenField sets the appropriate refresh token field in the config
|
||||
func setRefreshTokenField(apiConfig *cline.ModelsApiConfiguration, fieldName string, value *string) {
|
||||
switch fieldName {
|
||||
case "ocaRefreshToken":
|
||||
apiConfig.OcaRefreshToken = value
|
||||
}
|
||||
}
|
||||
|
||||
// setModeField sets the appropriate mode field in the config
|
||||
func setModeField(apiConfig *cline.ModelsApiConfiguration, fieldName string, value *string) {
|
||||
switch fieldName {
|
||||
case "ocaMode":
|
||||
apiConfig.OcaMode = value
|
||||
}
|
||||
}
|
||||
|
||||
// BedrockOptionalFields holds optional configuration fields for AWS Bedrock
|
||||
type BedrockOptionalFields struct {
|
||||
SessionToken *string // Optional: AWS session token for temporary credentials
|
||||
Region *string // Optional: AWS region
|
||||
UseCrossRegionInference *bool // Optional: Enable cross-region inference
|
||||
UseGlobalInference *bool // Optional: Use global inference endpoint
|
||||
UsePromptCache *bool // Optional: Enable prompt caching
|
||||
Authentication *string // Optional: Authentication method
|
||||
UseProfile *bool // Optional: Use AWS profile
|
||||
Profile *string // Optional: AWS profile name
|
||||
Endpoint *string // Optional: Custom endpoint URL
|
||||
}
|
||||
|
||||
// OcaOptionalFields holds optional configuration fields for Oracle Code Assist
|
||||
type OcaOptionalFields struct {
|
||||
BaseURL *string // Optional: Base URL
|
||||
Mode *string // Optional: Mode ("internal" or "external")
|
||||
}
|
||||
|
||||
// setBedrockOptionalFields sets optional Bedrock-specific fields in the API configuration
|
||||
func setBedrockOptionalFields(apiConfig *cline.ModelsApiConfiguration, fields *BedrockOptionalFields) {
|
||||
if fields == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if fields.SessionToken != nil {
|
||||
apiConfig.AwsSessionToken = fields.SessionToken
|
||||
}
|
||||
if fields.Region != nil {
|
||||
apiConfig.AwsRegion = fields.Region
|
||||
}
|
||||
if fields.UseCrossRegionInference != nil {
|
||||
apiConfig.AwsUseCrossRegionInference = fields.UseCrossRegionInference
|
||||
}
|
||||
if fields.UseGlobalInference != nil {
|
||||
apiConfig.AwsUseGlobalInference = fields.UseGlobalInference
|
||||
}
|
||||
if fields.UsePromptCache != nil {
|
||||
apiConfig.AwsBedrockUsePromptCache = fields.UsePromptCache
|
||||
}
|
||||
if fields.Authentication != nil {
|
||||
apiConfig.AwsAuthentication = fields.Authentication
|
||||
}
|
||||
if fields.UseProfile != nil {
|
||||
apiConfig.AwsUseProfile = fields.UseProfile
|
||||
}
|
||||
if fields.Profile != nil {
|
||||
apiConfig.AwsProfile = fields.Profile
|
||||
}
|
||||
if fields.Endpoint != nil {
|
||||
apiConfig.AwsBedrockEndpoint = fields.Endpoint
|
||||
}
|
||||
}
|
||||
|
||||
// setOcaOptionalFields sets optional Oca-specific fields in the API configuration
|
||||
func setOcaOptionalFields(apiConfig *cline.ModelsApiConfiguration, fields *OcaOptionalFields) {
|
||||
if fields == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if fields.Mode != nil {
|
||||
apiConfig.OcaMode = fields.Mode
|
||||
}
|
||||
if fields.BaseURL != nil {
|
||||
apiConfig.OcaBaseUrl = fields.BaseURL
|
||||
}
|
||||
}
|
||||
|
||||
// buildBedrockOptionalFieldMask builds field mask paths for Bedrock optional fields that have values
|
||||
func buildBedrockOptionalFieldMask(fields *BedrockOptionalFields) []string {
|
||||
if fields == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var fieldPaths []string
|
||||
|
||||
if fields.SessionToken != nil {
|
||||
fieldPaths = append(fieldPaths, "awsSessionToken")
|
||||
}
|
||||
if fields.Region != nil {
|
||||
fieldPaths = append(fieldPaths, "awsRegion")
|
||||
}
|
||||
if fields.UseCrossRegionInference != nil {
|
||||
fieldPaths = append(fieldPaths, "awsUseCrossRegionInference")
|
||||
}
|
||||
if fields.UseGlobalInference != nil {
|
||||
fieldPaths = append(fieldPaths, "awsUseGlobalInference")
|
||||
}
|
||||
if fields.UsePromptCache != nil {
|
||||
fieldPaths = append(fieldPaths, "awsBedrockUsePromptCache")
|
||||
}
|
||||
if fields.Authentication != nil {
|
||||
fieldPaths = append(fieldPaths, "awsAuthentication")
|
||||
}
|
||||
if fields.UseProfile != nil {
|
||||
fieldPaths = append(fieldPaths, "awsUseProfile")
|
||||
}
|
||||
if fields.Profile != nil {
|
||||
fieldPaths = append(fieldPaths, "awsProfile")
|
||||
}
|
||||
if fields.Endpoint != nil {
|
||||
fieldPaths = append(fieldPaths, "awsBedrockEndpoint")
|
||||
}
|
||||
|
||||
return fieldPaths
|
||||
}
|
||||
|
||||
// buildOcaOptionalFieldMask builds field mask paths for Bedrock optional fields that have values
|
||||
func buildOcaOptionalFieldMask(fields *OcaOptionalFields) []string {
|
||||
if fields == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var fieldPaths []string
|
||||
|
||||
if fields.Mode != nil {
|
||||
fieldPaths = append(fieldPaths, "ocaMode")
|
||||
}
|
||||
if fields.BaseURL != nil {
|
||||
fieldPaths = append(fieldPaths, "ocaBaseUrl")
|
||||
}
|
||||
|
||||
return fieldPaths
|
||||
}
|
||||
@@ -1,764 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/task"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
// ProviderWizard handles the interactive provider configuration process
|
||||
type ProviderWizard struct {
|
||||
ctx context.Context
|
||||
manager *task.Manager
|
||||
}
|
||||
|
||||
// NewProviderWizard prepares a new provider configuration wizard
|
||||
func NewProviderWizard(ctx context.Context) (*ProviderWizard, error) {
|
||||
// Create task manager using auth instance from context
|
||||
manager, err := createTaskManager(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create task manager: %w", err)
|
||||
}
|
||||
|
||||
return &ProviderWizard{
|
||||
ctx: ctx,
|
||||
manager: manager,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// showMainMenu displays the main provider configuration menu
|
||||
func (pw *ProviderWizard) showMainMenu() (string, error) {
|
||||
var action string
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("What would you like to do?").
|
||||
Options(
|
||||
huh.NewOption("Add or change an API provider", "add"),
|
||||
huh.NewOption("Change model for API provider", "change-model"),
|
||||
huh.NewOption("Remove a provider", "remove"),
|
||||
huh.NewOption("List configured providers", "list"),
|
||||
huh.NewOption("Return to main auth menu", "back"),
|
||||
).
|
||||
Value(&action),
|
||||
),
|
||||
)
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return "", fmt.Errorf("failed to get menu choice: %w", err)
|
||||
}
|
||||
|
||||
return action, nil
|
||||
}
|
||||
|
||||
// Run runs the provider configuration wizard
|
||||
func (pw *ProviderWizard) Run() error {
|
||||
|
||||
for {
|
||||
action, err := pw.showMainMenu()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "add":
|
||||
if err := pw.handleAddProvider(); err != nil {
|
||||
return err
|
||||
}
|
||||
case "change-model":
|
||||
if err := pw.handleChangeModel(); err != nil {
|
||||
return err
|
||||
}
|
||||
case "remove":
|
||||
if err := pw.handleRemoveProvider(); err != nil {
|
||||
return err
|
||||
}
|
||||
case "list":
|
||||
if err := pw.handleListProviders(); err != nil {
|
||||
return err
|
||||
}
|
||||
case "back":
|
||||
// Return to main auth menu
|
||||
return HandleAuthMenuNoArgs(pw.ctx)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
// "Add a new provider" > handleAddProvider
|
||||
func (pw *ProviderWizard) handleAddProvider() error {
|
||||
// Step 1: Select provider
|
||||
provider, err := SelectBYOProvider()
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "cancelled") {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("provider selection failed: %w", err)
|
||||
}
|
||||
|
||||
// Step 2: Special handling for Bedrock provider
|
||||
if provider == cline.ApiProvider_BEDROCK {
|
||||
return pw.handleAddBedrockProvider()
|
||||
}
|
||||
|
||||
// Step 2b: Special handling for OCA provider
|
||||
if provider == cline.ApiProvider_OCA {
|
||||
return pw.handleAddOcaProvider()
|
||||
}
|
||||
|
||||
// Step 3: Get API key first (for non-Bedrock providers)
|
||||
apiKey, baseURL, err := PromptForAPIKey(provider)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API key: %w", err)
|
||||
}
|
||||
|
||||
// Step 4: Try to fetch models and let user select (with fallback to manual entry for providers that don't support fetch)
|
||||
modelID, modelInfo, err := pw.selectModel(provider, apiKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("model selection failed: %w", err)
|
||||
}
|
||||
|
||||
// Step 5: Apply configuration using AddProviderPartial
|
||||
if err := AddProviderPartial(pw.ctx, pw.manager, provider, modelID, apiKey, baseURL, modelInfo); err != nil {
|
||||
return fmt.Errorf("failed to save configuration: %w", err)
|
||||
}
|
||||
|
||||
if err := setWelcomeViewCompleted(pw.ctx, pw.manager); err != nil {
|
||||
verboseLog("Warning: Failed to mark welcome view as completed: %v", err)
|
||||
}
|
||||
|
||||
fmt.Println("✓ Provider configured successfully!")
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleAddBedrockProvider handles the special case of adding Bedrock provider with its multi-field form
|
||||
func (pw *ProviderWizard) handleAddBedrockProvider() error {
|
||||
// Step 1: Get Bedrock configuration (all credentials and optional fields)
|
||||
config, err := PromptForBedrockConfig(pw.ctx, pw.manager)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "user declined profile authentication") {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to get Bedrock configuration: %w", err)
|
||||
}
|
||||
|
||||
// Step 2: Select model
|
||||
modelID, modelInfo, err := pw.selectModel(cline.ApiProvider_BEDROCK, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("model selection failed: %w", err)
|
||||
}
|
||||
|
||||
// Step 3: Apply Bedrock configuration
|
||||
if err := ApplyBedrockConfig(pw.ctx, pw.manager, config, modelID, modelInfo); err != nil {
|
||||
return fmt.Errorf("failed to save Bedrock configuration: %w", err)
|
||||
}
|
||||
|
||||
if err := setWelcomeViewCompleted(pw.ctx, pw.manager); err != nil {
|
||||
verboseLog("Warning: Failed to mark welcome view as completed: %v", err)
|
||||
}
|
||||
|
||||
fmt.Println("✓ Bedrock provider configured successfully!")
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleAddOcaProvider handles adding Oracle Code Assist provider with optional settings and auth
|
||||
func (pw *ProviderWizard) handleAddOcaProvider() error {
|
||||
// Step 1: Get OCA configuration (base URL and mode)
|
||||
config, err := PromptForOcaConfig(pw.ctx, pw.manager)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "user aborted") || strings.Contains(err.Error(), "cancelled") {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to get OCA configuration: %w", err)
|
||||
}
|
||||
|
||||
// Apply OCA configuration (base URL and mode)
|
||||
if err := ApplyOcaConfig(pw.ctx, pw.manager, config); err != nil {
|
||||
return fmt.Errorf("failed to save OCA configuration: %w", err)
|
||||
}
|
||||
|
||||
// Step 2: Ensure OCA authentication
|
||||
if err := ensureOcaAuthenticated(pw.ctx); err != nil {
|
||||
return fmt.Errorf("failed to authenticate with OCA: %w", err)
|
||||
}
|
||||
|
||||
// Step 3: Select model
|
||||
modelID, _, err := pw.selectModel(cline.ApiProvider_OCA, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("model selection failed: %w", err)
|
||||
}
|
||||
|
||||
// Step 4: Apply the OCA model configuration and set as active
|
||||
updates := ProviderUpdatesPartial{
|
||||
ModelID: &modelID,
|
||||
ModelInfo: nil,
|
||||
}
|
||||
|
||||
if err := UpdateProviderPartial(pw.ctx, pw.manager, cline.ApiProvider_OCA, updates, true); err != nil {
|
||||
return fmt.Errorf("failed to save OCA configuration: %w", err)
|
||||
}
|
||||
|
||||
if err := setWelcomeViewCompleted(pw.ctx, pw.manager); err != nil {
|
||||
verboseLog("Warning: Failed to mark welcome view as completed: %v", err)
|
||||
}
|
||||
|
||||
fmt.Println("✓ OCA provider configured successfully!")
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleListProviders retrieves and displays configured providers
|
||||
func (pw *ProviderWizard) handleListProviders() error {
|
||||
result, err := GetProviderConfigurations(pw.ctx, pw.manager)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to retrieve provider configurations: %w", err)
|
||||
}
|
||||
|
||||
output := FormatProviderList(result)
|
||||
fmt.Println(output)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// selectModel attempts to fetch available models and let user select, or falls back to manual entry
|
||||
func (pw *ProviderWizard) selectModel(provider cline.ApiProvider, apiKey string) (string, interface{}, error) {
|
||||
// For providers that support model fetching, try to fetch and display models
|
||||
canFetchModels := pw.supportsModelFetching(provider)
|
||||
|
||||
if canFetchModels {
|
||||
fmt.Println("Fetching available models...")
|
||||
models, modelInfoMap, err := pw.fetchModelsForProvider(provider, apiKey)
|
||||
|
||||
if err != nil {
|
||||
fmt.Println("\n⚠ Unable to fetch model list from the provider. Please enter the model ID manually instead.")
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf(" Error details: %v\n", err)
|
||||
}
|
||||
return pw.manualModelEntry(provider)
|
||||
}
|
||||
|
||||
if len(models) == 0 {
|
||||
fmt.Println("\n⚠ No models found from the provider. Please enter the model ID manually instead.")
|
||||
return pw.manualModelEntry(provider)
|
||||
}
|
||||
|
||||
// Let user select from available models (includes manual entry option)
|
||||
modelID, err := pw.selectFromAvailableModels(models)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("model selection failed: %w", err)
|
||||
}
|
||||
|
||||
// Check if user chose manual entry
|
||||
const manualEntryKey = "__MANUAL_ENTRY__"
|
||||
if modelID == manualEntryKey {
|
||||
return pw.manualModelEntry(provider)
|
||||
}
|
||||
|
||||
// Get the model info for the selected model
|
||||
var modelInfo interface{}
|
||||
if modelInfoMap != nil {
|
||||
modelInfo = modelInfoMap[modelID]
|
||||
}
|
||||
|
||||
return modelID, modelInfo, nil
|
||||
}
|
||||
|
||||
// For providers without model fetching support, use manual entry
|
||||
return pw.manualModelEntry(provider)
|
||||
}
|
||||
|
||||
// supportsModelFetching returns true if the provider supports fetching models
|
||||
func (pw *ProviderWizard) supportsModelFetching(provider cline.ApiProvider) bool {
|
||||
return SupportsBYOModelFetching(provider)
|
||||
}
|
||||
|
||||
// fetchModelsForProvider fetches models for a given provider
|
||||
// Supports both dynamic API fetching (OpenRouter, OpenAI, Ollama) and static model lists (Anthropic, Bedrock, Gemini, X AI)
|
||||
func (pw *ProviderWizard) fetchModelsForProvider(provider cline.ApiProvider, apiKey string) ([]string, map[string]interface{}, error) {
|
||||
// Try dynamic/remote model fetching first
|
||||
switch provider {
|
||||
case cline.ApiProvider_OPENROUTER:
|
||||
models, err := FetchOpenRouterModels(pw.ctx, pw.manager)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
interfaceMap := ConvertOpenRouterModelsToInterface(models)
|
||||
return ConvertModelsMapToSlice(interfaceMap), interfaceMap, nil
|
||||
|
||||
case cline.ApiProvider_OPENAI:
|
||||
// For OpenAI, we need to pass the base URL and API key
|
||||
baseURL := "https://api.openai.com/v1" // Default OpenAI API base URL
|
||||
modelIDs, err := FetchOpenAiModels(pw.ctx, pw.manager, baseURL, apiKey)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// OpenAI returns just model IDs without additional info, so modelInfo map is nil
|
||||
return modelIDs, nil, nil
|
||||
|
||||
case cline.ApiProvider_OLLAMA:
|
||||
// For Ollama, apiKey actually contains the base URL (or empty for default)
|
||||
baseURL := apiKey // The "API key" field for Ollama is actually the base URL
|
||||
modelIDs, err := FetchOllamaModels(pw.ctx, pw.manager, baseURL)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// Ollama returns just model IDs without additional info, so modelInfo map is nil
|
||||
return modelIDs, nil, nil
|
||||
|
||||
case cline.ApiProvider_OCA:
|
||||
// OCA supports dynamic model fetching
|
||||
models, err := FetchOcaModels(pw.ctx, pw.manager)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
interfaceMap := ConvertOcaModelsToInterface(models)
|
||||
return ConvertModelsMapToSlice(interfaceMap), interfaceMap, nil
|
||||
}
|
||||
|
||||
// Fall back to static models for providers that don't support dynamic fetching
|
||||
if SupportsStaticModelList(provider) {
|
||||
modelIDs, _, err := FetchStaticModels(provider)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// Static models don't have detailed info maps for now, so modelInfo map is nil
|
||||
return modelIDs, nil, nil
|
||||
}
|
||||
|
||||
return nil, nil, fmt.Errorf("model fetching not supported for provider: %v", provider)
|
||||
}
|
||||
|
||||
// selectFromAvailableModels displays available models and lets user select one.
|
||||
// Includes an option to enter a model ID manually in case the desired model isn't listed.
|
||||
func (pw *ProviderWizard) selectFromAvailableModels(models []string) (string, error) {
|
||||
if len(models) == 0 {
|
||||
return "", fmt.Errorf("no models available")
|
||||
}
|
||||
|
||||
// Add a special "manual entry" option at the end
|
||||
const manualEntryKey = "__MANUAL_ENTRY__"
|
||||
|
||||
// Use model ID as the value (not index)
|
||||
var selectedModel string
|
||||
options := make([]huh.Option[string], len(models)+1)
|
||||
for i, model := range models {
|
||||
options[i] = huh.NewOption(model, model)
|
||||
}
|
||||
// Add manual entry option at the end
|
||||
options[len(models)] = huh.NewOption("Enter model ID manually...", manualEntryKey)
|
||||
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("Select a model").
|
||||
Options(options...).
|
||||
Height(calculateSelectHeight()).
|
||||
Filtering(true).
|
||||
Value(&selectedModel),
|
||||
),
|
||||
)
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return "", fmt.Errorf("failed to select model: %w", err)
|
||||
}
|
||||
|
||||
// If user selected manual entry, return special key to trigger manual input
|
||||
if selectedModel == manualEntryKey {
|
||||
return manualEntryKey, nil
|
||||
}
|
||||
|
||||
return selectedModel, nil
|
||||
}
|
||||
|
||||
// manualModelEntry prompts user to manually enter a model ID.
|
||||
// Returns the model ID and an error. The modelInfo is always nil for manual entry.
|
||||
func (pw *ProviderWizard) manualModelEntry(provider cline.ApiProvider) (string, interface{}, error) {
|
||||
var modelID string
|
||||
modelPlaceholder := GetBYOProviderPlaceholder(provider)
|
||||
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("Model ID").
|
||||
Placeholder(modelPlaceholder).
|
||||
Value(&modelID).
|
||||
Validate(func(s string) error {
|
||||
// Trim whitespace and validate
|
||||
trimmed := strings.TrimSpace(s)
|
||||
if trimmed == "" {
|
||||
return fmt.Errorf("model ID cannot be empty")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return "", nil, fmt.Errorf("failed to get model ID: %w", err)
|
||||
}
|
||||
|
||||
// Trim whitespace from the final value
|
||||
modelID = strings.TrimSpace(modelID)
|
||||
|
||||
// modelInfo is always nil for manual entry
|
||||
return modelID, nil, nil
|
||||
}
|
||||
|
||||
// handleChangeModel allows changing the model for any configured provider
|
||||
func (pw *ProviderWizard) handleChangeModel() error {
|
||||
// Step 1: Get current provider configurations
|
||||
result, err := GetProviderConfigurations(pw.ctx, pw.manager)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to retrieve provider configurations: %w", err)
|
||||
}
|
||||
|
||||
// Step 2: Get all configured providers with models
|
||||
readyProviders := result.GetAllReadyProviders()
|
||||
|
||||
// Filter out Cline provider (it has its own model changer in the main menu)
|
||||
var configurableProviders []*ProviderDisplay
|
||||
for _, provider := range readyProviders {
|
||||
if provider.Provider != cline.ApiProvider_CLINE {
|
||||
configurableProviders = append(configurableProviders, provider)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Check if there are any configurable providers
|
||||
if len(configurableProviders) == 0 {
|
||||
fmt.Println("\nNo configurable providers found.")
|
||||
fmt.Println("Note: Cline provider has its own model selection in the main menu.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Step 4: Let user select which provider to change the model for
|
||||
var selectedIndex int
|
||||
options := make([]huh.Option[int], len(configurableProviders)+1)
|
||||
for i, providerDisplay := range configurableProviders {
|
||||
displayName := fmt.Sprintf("%s (current: %s)",
|
||||
GetProviderDisplayName(providerDisplay.Provider),
|
||||
providerDisplay.ModelID)
|
||||
options[i] = huh.NewOption(displayName, i)
|
||||
}
|
||||
options[len(configurableProviders)] = huh.NewOption("(Cancel)", -1)
|
||||
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[int]().
|
||||
Title("Select provider to change model for").
|
||||
Options(options...).
|
||||
Value(&selectedIndex),
|
||||
),
|
||||
)
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return fmt.Errorf("failed to select provider: %w", err)
|
||||
}
|
||||
|
||||
if selectedIndex == -1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
selectedProvider := configurableProviders[selectedIndex]
|
||||
provider := selectedProvider.Provider
|
||||
|
||||
fmt.Printf("\nChanging model for %s\n", GetProviderDisplayName(provider))
|
||||
fmt.Printf("Current model: %s\n\n", selectedProvider.ModelID)
|
||||
|
||||
// Step 5: Retrieve API key if needed for model fetching
|
||||
var apiKey string
|
||||
if pw.supportsModelFetching(provider) {
|
||||
// For providers that support fetching, we need to retrieve the API key from state
|
||||
state, err := pw.manager.GetClient().State.GetLatestState(pw.ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get state: %w", err)
|
||||
}
|
||||
|
||||
var stateData map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(state.StateJson), &stateData); err != nil {
|
||||
return fmt.Errorf("failed to parse state JSON: %w", err)
|
||||
}
|
||||
|
||||
apiConfig, ok := stateData["apiConfiguration"].(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("no API configuration found in state")
|
||||
}
|
||||
|
||||
apiKey = getProviderAPIKeyFromState(apiConfig, provider)
|
||||
if apiKey == "" {
|
||||
return fmt.Errorf("no API key found for provider %s", GetProviderDisplayName(provider))
|
||||
}
|
||||
}
|
||||
|
||||
modelID, modelInfo, err := pw.selectModel(provider, apiKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("model selection failed: %w", err)
|
||||
}
|
||||
|
||||
// Step 6: Apply the model change (for both Plan and Act modes)
|
||||
if err := pw.applyModelChange(provider, modelID, modelInfo); err != nil {
|
||||
return fmt.Errorf("failed to apply model change: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Model changed successfully to: %s\n", modelID)
|
||||
fmt.Println(" (Applied to both Plan and Act modes)")
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyModelChange applies a model change for both Plan and Act modes using UpdateProviderPartial
|
||||
func (pw *ProviderWizard) applyModelChange(provider cline.ApiProvider, modelID string, modelInfo interface{}) error {
|
||||
updates := ProviderUpdatesPartial{
|
||||
ModelID: &modelID,
|
||||
ModelInfo: modelInfo,
|
||||
}
|
||||
|
||||
return UpdateProviderPartial(pw.ctx, pw.manager, provider, updates, true)
|
||||
}
|
||||
|
||||
// SwitchToBYOProvider switches to a BYO provider that's already configured.
|
||||
// It retrieves the existing model configuration and sets it as the active provider for both Plan and Act modes.
|
||||
func SwitchToBYOProvider(ctx context.Context, manager *task.Manager, provider cline.ApiProvider) error {
|
||||
// Get the current state to retrieve the model ID and model info for this provider
|
||||
state, err := manager.GetClient().State.GetLatestState(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get state: %w", err)
|
||||
}
|
||||
|
||||
// Parse state JSON
|
||||
var stateData map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(state.StateJson), &stateData); err != nil {
|
||||
return fmt.Errorf("failed to parse state JSON: %w", err)
|
||||
}
|
||||
|
||||
// Extract apiConfiguration
|
||||
apiConfig, ok := stateData["apiConfiguration"].(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("no API configuration found in state")
|
||||
}
|
||||
|
||||
// Get the model ID for the selected provider
|
||||
modelID := getProviderModelIDFromState(apiConfig, provider)
|
||||
if modelID == "" {
|
||||
return fmt.Errorf("no model configured for provider %s", GetProviderDisplayName(provider))
|
||||
}
|
||||
|
||||
// Get model info if available (for OpenRouter/Cline)
|
||||
var modelInfo interface{}
|
||||
if provider == cline.ApiProvider_OPENROUTER || provider == cline.ApiProvider_CLINE {
|
||||
if modelInfoData, ok := apiConfig["planModeOpenRouterModelInfo"].(map[string]interface{}); ok {
|
||||
modelInfo = convertMapToOpenRouterModelInfo(modelInfoData)
|
||||
}
|
||||
}
|
||||
|
||||
// Use UpdateProviderPartial to switch to this provider
|
||||
updates := ProviderUpdatesPartial{
|
||||
ModelID: &modelID,
|
||||
ModelInfo: modelInfo,
|
||||
}
|
||||
|
||||
if err := UpdateProviderPartial(ctx, manager, provider, updates, true); err != nil {
|
||||
return fmt.Errorf("failed to switch provider: %w", err)
|
||||
}
|
||||
|
||||
verboseLog("✓ Switched to %s\n", GetProviderDisplayName(provider))
|
||||
verboseLog(" Using model: %s\n", modelID)
|
||||
|
||||
return HandleAuthMenuNoArgs(ctx)
|
||||
}
|
||||
|
||||
// getProviderModelIDFromState retrieves the model ID for a specific provider from state
|
||||
func getProviderModelIDFromState(stateData map[string]interface{}, provider cline.ApiProvider) string {
|
||||
modelKey, err := GetModelIDFieldName(provider, "plan")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if modelID, ok := stateData[modelKey].(string); ok {
|
||||
return modelID
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// getProviderAPIKeyFromState retrieves the API key for a specific provider from state
|
||||
func getProviderAPIKeyFromState(stateData map[string]interface{}, provider cline.ApiProvider) string {
|
||||
// OCA uses account authentication, not API keys. Consider it "present" if authenticated.
|
||||
if provider == cline.ApiProvider_OCA {
|
||||
if state, _ := GetLatestOCAState(context.TODO(), 2 * time.Second); state != nil && state.User != nil {
|
||||
// Return a sentinel non-empty string so upstream checks pass.
|
||||
return "OCA_AUTH_VERIFIED"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
fields, err := GetProviderFields(provider)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if apiKey, ok := stateData[fields.APIKeyField].(string); ok {
|
||||
return apiKey
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// convertMapToOpenRouterModelInfo converts a map to OpenRouterModelInfo
|
||||
func convertMapToOpenRouterModelInfo(data map[string]interface{}) *cline.OpenRouterModelInfo {
|
||||
info := &cline.OpenRouterModelInfo{}
|
||||
|
||||
if val, ok := data["description"].(string); ok {
|
||||
info.Description = &val
|
||||
}
|
||||
if val, ok := data["contextWindow"].(float64); ok {
|
||||
contextWindow := int64(val)
|
||||
info.ContextWindow = &contextWindow
|
||||
}
|
||||
if val, ok := data["maxTokens"].(float64); ok {
|
||||
maxTokens := int64(val)
|
||||
info.MaxTokens = &maxTokens
|
||||
}
|
||||
if val, ok := data["inputPrice"].(float64); ok {
|
||||
info.InputPrice = &val
|
||||
}
|
||||
if val, ok := data["outputPrice"].(float64); ok {
|
||||
info.OutputPrice = &val
|
||||
}
|
||||
if val, ok := data["cacheWritesPrice"].(float64); ok {
|
||||
info.CacheWritesPrice = &val
|
||||
}
|
||||
if val, ok := data["cacheReadsPrice"].(float64); ok {
|
||||
info.CacheReadsPrice = &val
|
||||
}
|
||||
if val, ok := data["supportsImages"].(bool); ok {
|
||||
info.SupportsImages = &val
|
||||
}
|
||||
if val, ok := data["supportsPromptCache"].(bool); ok {
|
||||
info.SupportsPromptCache = val
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
// handleRemoveProvider allows removing a configured provider by clearing its API key
|
||||
func (pw *ProviderWizard) handleRemoveProvider() error {
|
||||
// Step 1: Get current provider configurations
|
||||
result, err := GetProviderConfigurations(pw.ctx, pw.manager)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to retrieve provider configurations: %w", err)
|
||||
}
|
||||
|
||||
// Step 2: Get all ready providers
|
||||
readyProviders := result.GetAllReadyProviders()
|
||||
|
||||
// Filter out Cline provider (uses account auth, not API keys)
|
||||
var removableProviders []*ProviderDisplay
|
||||
for _, provider := range readyProviders {
|
||||
if provider.Provider != cline.ApiProvider_CLINE {
|
||||
removableProviders = append(removableProviders, provider)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Check if there are providers to remove
|
||||
if len(removableProviders) == 0 {
|
||||
fmt.Println("\nNo providers available to remove.")
|
||||
fmt.Println("Note: Cline provider cannot be removed via this menu.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Step 4: Display selection menu
|
||||
var selectedIndex int
|
||||
options := make([]huh.Option[int], len(removableProviders))
|
||||
for i, provider := range removableProviders {
|
||||
// Mark active provider
|
||||
displayName := GetProviderDisplayName(provider.Provider)
|
||||
if result.ActProvider != nil && provider.Provider == result.ActProvider.Provider {
|
||||
displayName += " (ACTIVE)"
|
||||
}
|
||||
options[i] = huh.NewOption(displayName, i)
|
||||
}
|
||||
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[int]().
|
||||
Title("Select provider to remove").
|
||||
Options(options...).
|
||||
Value(&selectedIndex),
|
||||
),
|
||||
)
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return fmt.Errorf("failed to select provider: %w", err)
|
||||
}
|
||||
|
||||
selectedProvider := removableProviders[selectedIndex]
|
||||
|
||||
// Step 5: Check if trying to remove the active provider
|
||||
if result.ActProvider != nil && selectedProvider.Provider == result.ActProvider.Provider {
|
||||
fmt.Printf("\nCannot remove %s because it is currently active.\n", GetProviderDisplayName(selectedProvider.Provider))
|
||||
fmt.Println("Please switch to a different provider first, then try again.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Step 6: Confirm removal
|
||||
var confirm bool
|
||||
confirmForm := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewConfirm().
|
||||
Title(fmt.Sprintf("Are you sure you want to remove %s?", GetProviderDisplayName(selectedProvider.Provider))).
|
||||
Description("This will clear the API key but preserve the model configuration.").
|
||||
Value(&confirm),
|
||||
),
|
||||
)
|
||||
|
||||
if err := confirmForm.Run(); err != nil {
|
||||
return fmt.Errorf("failed to get confirmation: %w", err)
|
||||
}
|
||||
|
||||
if !confirm {
|
||||
fmt.Println("Removal cancelled.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Step 7: If removing OCA, sign out first
|
||||
if selectedProvider.Provider == cline.ApiProvider_OCA {
|
||||
if err := signOutOca(pw.ctx); err != nil {
|
||||
fmt.Printf("Warning: Failed to sign out of OCA: %v\n", err)
|
||||
} else {
|
||||
fmt.Println("Signed out of OCA.")
|
||||
}
|
||||
}
|
||||
|
||||
// Step 8: Clear the API key for the selected provider
|
||||
if err := pw.clearProviderAPIKey(selectedProvider.Provider); err != nil {
|
||||
return fmt.Errorf("failed to remove provider: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\n✓ %s removed successfully\n", GetProviderDisplayName(selectedProvider.Provider))
|
||||
return nil
|
||||
}
|
||||
|
||||
// clearProviderAPIKey clears the API key field for a specific provider using RemoveProviderPartial
|
||||
func (pw *ProviderWizard) clearProviderAPIKey(provider cline.ApiProvider) error {
|
||||
return RemoveProviderPartial(pw.ctx, pw.manager, provider)
|
||||
}
|
||||
|
||||
|
||||
func signOutOca(ctx context.Context) error {
|
||||
client, err := global.GetDefaultClient(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = client.Ocaaccount.OcaAccountLogoutClicked(ctx, &cline.EmptyRequest{})
|
||||
return err
|
||||
}
|
||||
|
||||
func setWelcomeViewCompleted(ctx context.Context, manager *task.Manager) error {
|
||||
_, err := manager.GetClient().State.SetWelcomeViewCompleted(ctx, &cline.BooleanRequest{Value: true})
|
||||
return err
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/cline/cli/pkg/cli/task"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/types/known/fieldmaskpb"
|
||||
)
|
||||
|
||||
// BedrockConfig holds all AWS Bedrock-specific configuration fields
|
||||
type BedrockConfig struct {
|
||||
// Profile authentication fields
|
||||
UseProfile bool // Always true for successful config
|
||||
Profile string // Optional: AWS profile name (empty = default)
|
||||
Region string // Required: AWS region
|
||||
Endpoint string // Optional: Custom VPC endpoint URL
|
||||
|
||||
// Optional features
|
||||
UseCrossRegionInference bool // Optional: Enable cross-region inference
|
||||
UseGlobalInference bool // Optional: Use global inference endpoint
|
||||
UsePromptCache bool // Optional: Enable prompt caching
|
||||
|
||||
// Authentication method (always "profile")
|
||||
Authentication string // Always set to "profile"
|
||||
|
||||
// Legacy fields (no longer used in profile-only flow)
|
||||
AccessKey string // No longer used
|
||||
SecretKey string // No longer used
|
||||
SessionToken string // No longer used
|
||||
}
|
||||
|
||||
// PromptForBedrockConfig displays a profile-first authentication form for Bedrock configuration
|
||||
func PromptForBedrockConfig(ctx context.Context, manager *task.Manager) (*BedrockConfig, error) {
|
||||
config := &BedrockConfig{}
|
||||
|
||||
// First, ask if user wants to use AWS profile authentication
|
||||
var useProfile bool
|
||||
profileQuestion := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewConfirm().
|
||||
Title("Do you want to use an AWS profile for authentication?").
|
||||
Description("AWS profiles are managed via 'aws configure'").
|
||||
Value(&useProfile).
|
||||
Affirmative("Yes").
|
||||
Negative("No").
|
||||
Inline(true),
|
||||
),
|
||||
)
|
||||
|
||||
if err := profileQuestion.Run(); err != nil {
|
||||
return nil, fmt.Errorf("failed to get authentication method: %w", err)
|
||||
}
|
||||
|
||||
// If user declines profile authentication, show message and return error
|
||||
if !useProfile {
|
||||
fmt.Println("\nAWS profile authentication is currently the only supported method in the CLI.")
|
||||
fmt.Println("Please configure an AWS profile using 'aws configure' and try again.")
|
||||
return nil, fmt.Errorf("user declined profile authentication")
|
||||
}
|
||||
|
||||
// User wants profile auth - collect profile configuration
|
||||
config.UseProfile = true
|
||||
config.Authentication = "profile"
|
||||
|
||||
// Collect profile name, region, and optional settings
|
||||
configForm := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("AWS Profile Name (optional, press Enter for default profile)").
|
||||
Value(&config.Profile).
|
||||
Description("Leave empty to use default AWS profile"),
|
||||
|
||||
huh.NewInput().
|
||||
Title("AWS Region (required, e.g., us-east-1)").
|
||||
Value(&config.Region).
|
||||
Validate(func(s string) error {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return fmt.Errorf("AWS Region is required")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
|
||||
huh.NewInput().
|
||||
Title("Custom VPC Endpoint URL (optional)").
|
||||
Value(&config.Endpoint).
|
||||
Description("Press Enter to skip"),
|
||||
|
||||
huh.NewConfirm().
|
||||
Title("Enable Prompt Cache? ").
|
||||
Value(&config.UsePromptCache).
|
||||
Affirmative("Yes").
|
||||
Negative("No").
|
||||
Inline(true),
|
||||
|
||||
huh.NewConfirm().
|
||||
Title("Enable Cross-Region Inference? ").
|
||||
Value(&config.UseCrossRegionInference).
|
||||
Affirmative("Yes").
|
||||
Negative("No").
|
||||
Inline(true),
|
||||
|
||||
huh.NewConfirm().
|
||||
Title("Use Global Inference Endpoint? ").
|
||||
Value(&config.UseGlobalInference).
|
||||
Affirmative("Yes").
|
||||
Negative("No").
|
||||
Inline(true),
|
||||
),
|
||||
)
|
||||
|
||||
if err := configForm.Run(); err != nil {
|
||||
return nil, fmt.Errorf("failed to get Bedrock configuration: %w", err)
|
||||
}
|
||||
|
||||
// Trim whitespace from string fields
|
||||
config.Profile = strings.TrimSpace(config.Profile)
|
||||
config.Region = strings.TrimSpace(config.Region)
|
||||
config.Endpoint = strings.TrimSpace(config.Endpoint)
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// ApplyBedrockConfig applies Bedrock configuration using partial updates (profile-only)
|
||||
func ApplyBedrockConfig(ctx context.Context, manager *task.Manager, config *BedrockConfig, modelID string, modelInfo interface{}) error {
|
||||
// Build the API configuration with all Bedrock fields
|
||||
apiConfig := &cline.ModelsApiConfiguration{}
|
||||
|
||||
// Set model ID fields
|
||||
apiConfig.PlanModeApiModelId = proto.String(modelID)
|
||||
apiConfig.ActModeApiModelId = proto.String(modelID)
|
||||
apiConfig.PlanModeAwsBedrockCustomModelBaseId = proto.String(modelID)
|
||||
apiConfig.ActModeAwsBedrockCustomModelBaseId = proto.String(modelID)
|
||||
|
||||
// Set profile authentication fields (always required)
|
||||
optionalFields := &BedrockOptionalFields{}
|
||||
optionalFields.Authentication = proto.String("profile")
|
||||
optionalFields.UseProfile = proto.Bool(true)
|
||||
optionalFields.Region = proto.String(config.Region)
|
||||
|
||||
// Set profile name (can be empty for default profile)
|
||||
if config.Profile != "" {
|
||||
optionalFields.Profile = proto.String(config.Profile)
|
||||
}
|
||||
|
||||
// Set optional fields if provided
|
||||
if config.Endpoint != "" {
|
||||
optionalFields.Endpoint = proto.String(config.Endpoint)
|
||||
}
|
||||
if config.UseCrossRegionInference {
|
||||
optionalFields.UseCrossRegionInference = proto.Bool(true)
|
||||
}
|
||||
if config.UseGlobalInference {
|
||||
optionalFields.UseGlobalInference = proto.Bool(true)
|
||||
}
|
||||
if config.UsePromptCache {
|
||||
optionalFields.UsePromptCache = proto.Bool(true)
|
||||
}
|
||||
|
||||
// Apply all fields to the config
|
||||
setBedrockOptionalFields(apiConfig, optionalFields)
|
||||
|
||||
// Build field mask including all fields we're setting (excluding access keys)
|
||||
fieldPaths := []string{
|
||||
"planModeApiModelId",
|
||||
"actModeApiModelId",
|
||||
"planModeAwsBedrockCustomModelBaseId",
|
||||
"actModeAwsBedrockCustomModelBaseId",
|
||||
}
|
||||
|
||||
// Add profile authentication field paths
|
||||
optionalPaths := buildBedrockOptionalFieldMask(optionalFields)
|
||||
fieldPaths = append(fieldPaths, optionalPaths...)
|
||||
|
||||
// Create field mask
|
||||
fieldMask := &fieldmaskpb.FieldMask{Paths: fieldPaths}
|
||||
|
||||
// Apply the partial update
|
||||
request := &cline.UpdateApiConfigurationPartialRequest{
|
||||
ApiConfiguration: apiConfig,
|
||||
UpdateMask: fieldMask,
|
||||
}
|
||||
|
||||
if err := updateApiConfigurationPartial(ctx, manager, request); err != nil {
|
||||
return fmt.Errorf("failed to apply Bedrock configuration: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,366 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/task"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/types/known/fieldmaskpb"
|
||||
)
|
||||
|
||||
// OcaConfig holds Oracle Code Assist (OCA) configuration fields
|
||||
type OcaConfig struct {
|
||||
BaseURL string
|
||||
Mode string
|
||||
}
|
||||
|
||||
// PromptForOcaConfig displays a form for OCA configuration (base URL and mode)
|
||||
func PromptForOcaConfig(ctx context.Context, manager *task.Manager) (*OcaConfig, error) {
|
||||
config := &OcaConfig{}
|
||||
var mode string
|
||||
|
||||
// Collect optional settings
|
||||
configForm := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("Base URL").
|
||||
Value(&config.BaseURL).
|
||||
Description("Leave empty to use default Base URL"),
|
||||
|
||||
huh.NewSelect[string]().
|
||||
Title("Choose OCA mode (used for authentication)").
|
||||
Description("Select 'Internal' to use Cline's internal OCA, or 'External' for your own OCA instance").
|
||||
Options(
|
||||
huh.NewOption("Internal", "internal"),
|
||||
huh.NewOption("External", "external"),
|
||||
).
|
||||
Value(&mode),
|
||||
),
|
||||
)
|
||||
|
||||
if err := configForm.Run(); err != nil {
|
||||
return nil, fmt.Errorf("failed to get OCA configuration: %w", err)
|
||||
}
|
||||
|
||||
// Trim whitespace from string fields
|
||||
config.BaseURL = strings.TrimSpace(config.BaseURL)
|
||||
config.Mode = strings.TrimSpace(mode)
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// ApplyOcaConfig applies OCA configuration using partial updates
|
||||
func ApplyOcaConfig(ctx context.Context, manager *task.Manager, config *OcaConfig) error {
|
||||
// Build the API configuration with all OCA fields
|
||||
apiConfig := &cline.ModelsApiConfiguration{}
|
||||
|
||||
// Set profile authentication fields (always required)
|
||||
optionalFields := &OcaOptionalFields{}
|
||||
|
||||
// Set profile name (can be empty for default profile)
|
||||
if config.BaseURL != "" {
|
||||
optionalFields.BaseURL = proto.String(config.BaseURL)
|
||||
}
|
||||
|
||||
// Set optional fields if provided
|
||||
if config.Mode != "" {
|
||||
optionalFields.Mode = proto.String(config.Mode)
|
||||
}
|
||||
|
||||
// Apply all fields to the config
|
||||
setOcaOptionalFields(apiConfig, optionalFields)
|
||||
|
||||
// Add profile authentication field paths
|
||||
optionalPaths := buildOcaOptionalFieldMask(optionalFields)
|
||||
|
||||
// Create field mask
|
||||
fieldMask := &fieldmaskpb.FieldMask{Paths: optionalPaths}
|
||||
|
||||
// Apply the partial update
|
||||
request := &cline.UpdateApiConfigurationPartialRequest{
|
||||
ApiConfiguration: apiConfig,
|
||||
UpdateMask: fieldMask,
|
||||
}
|
||||
|
||||
if err := updateApiConfigurationPartial(ctx, manager, request); err != nil {
|
||||
return fmt.Errorf("failed to apply OCA configuration: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// OCA Auth Listener Singleton
|
||||
// ===========================
|
||||
|
||||
type ocaAuthStream interface {
|
||||
Recv() (*cline.OcaAuthState, error)
|
||||
}
|
||||
|
||||
// OcaAuthStatusListener manages subscription to OCA auth status updates
|
||||
type OcaAuthStatusListener struct {
|
||||
stream ocaAuthStream
|
||||
updatesCh chan *cline.OcaAuthState
|
||||
errCh chan error
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
mu sync.RWMutex
|
||||
lastState *cline.OcaAuthState
|
||||
firstEventCh chan struct{}
|
||||
firstEventOnce sync.Once
|
||||
}
|
||||
|
||||
// NewOcaAuthStatusListener creates a new OCA auth status listener
|
||||
func NewOcaAuthStatusListener(parentCtx context.Context) (*OcaAuthStatusListener, error) {
|
||||
client, err := global.GetDefaultClient(parentCtx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get client: %w", err)
|
||||
}
|
||||
|
||||
// Keep the listener alive independently of short-lived caller contexts
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
// Subscribe to OCA auth status updates
|
||||
stream, err := client.Ocaaccount.OcaSubscribeToAuthStatusUpdate(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("failed to subscribe to OCA auth updates: %w", err)
|
||||
}
|
||||
|
||||
return &OcaAuthStatusListener{
|
||||
stream: stream,
|
||||
updatesCh: make(chan *cline.OcaAuthState, 10),
|
||||
errCh: make(chan error, 1),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
firstEventCh: make(chan struct{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Start begins listening to the auth status update stream
|
||||
func (l *OcaAuthStatusListener) Start() error {
|
||||
go l.readStream()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *OcaAuthStatusListener) readStream() {
|
||||
defer close(l.updatesCh)
|
||||
defer close(l.errCh)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-l.ctx.Done():
|
||||
return
|
||||
default:
|
||||
state, err := l.stream.Recv()
|
||||
if err != nil {
|
||||
// Propagate error and exit
|
||||
if err == io.EOF {
|
||||
// Treat as error to notify waiters
|
||||
err = fmt.Errorf("OCA auth status stream closed")
|
||||
}
|
||||
select {
|
||||
case l.errCh <- err:
|
||||
case <-l.ctx.Done():
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
l.mu.Lock()
|
||||
l.lastState = state
|
||||
l.mu.Unlock()
|
||||
|
||||
// Notify first event waiters
|
||||
l.firstEventOnce.Do(func() { close(l.firstEventCh) })
|
||||
|
||||
select {
|
||||
case l.updatesCh <- state:
|
||||
case <-l.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WaitForFirstEvent blocks until the first event is received or timeout occurs
|
||||
func (l *OcaAuthStatusListener) WaitForFirstEvent(timeout time.Duration) error {
|
||||
// Fast-path if already have a state
|
||||
l.mu.RLock()
|
||||
ready := l.lastState != nil
|
||||
l.mu.RUnlock()
|
||||
if ready {
|
||||
return nil
|
||||
}
|
||||
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case <-l.firstEventCh:
|
||||
return nil
|
||||
case <-timer.C:
|
||||
return fmt.Errorf("timeout waiting for initial OCA auth event")
|
||||
case <-l.ctx.Done():
|
||||
return fmt.Errorf("OCA auth listener cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
// IsAuthenticated returns true if the last known OCA auth state is authenticated
|
||||
func (l *OcaAuthStatusListener) IsAuthenticated() bool {
|
||||
l.mu.RLock()
|
||||
defer l.mu.RUnlock()
|
||||
return isOCAStateAuthenticated(l.lastState)
|
||||
}
|
||||
|
||||
// WaitForAuthentication waits until OCA authentication succeeds or timeout occurs
|
||||
func (l *OcaAuthStatusListener) WaitForAuthentication(timeout time.Duration) error {
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
|
||||
// If already authenticated, return immediately
|
||||
if l.IsAuthenticated() {
|
||||
return nil
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-timer.C:
|
||||
return fmt.Errorf("OCA authentication timeout after %v - please try again", timeout)
|
||||
case <-l.ctx.Done():
|
||||
return fmt.Errorf("OCA authentication cancelled")
|
||||
case err := <-l.errCh:
|
||||
return fmt.Errorf("OCA authentication stream error: %w", err)
|
||||
case state := <-l.updatesCh:
|
||||
if isOCAStateAuthenticated(state) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop closes the stream and cleans up resources
|
||||
func (l *OcaAuthStatusListener) Stop() {
|
||||
l.cancel()
|
||||
}
|
||||
|
||||
func isOCAStateAuthenticated(state *cline.OcaAuthState) bool {
|
||||
return state != nil && state.User != nil
|
||||
}
|
||||
|
||||
// Singleton holder
|
||||
var (
|
||||
ocaListener *OcaAuthStatusListener
|
||||
ocaListenerOnce sync.Once
|
||||
ocaListenerErr error
|
||||
)
|
||||
|
||||
// GetOcaAuthListener returns the OCA auth listener singleton
|
||||
func GetOcaAuthListener(ctx context.Context) (*OcaAuthStatusListener, error) {
|
||||
// Allow optional ctx: if nil, use context.TODO(). If already initialized, return singleton.
|
||||
if ctx == nil {
|
||||
ctx = context.TODO()
|
||||
}
|
||||
|
||||
ocaListenerOnce.Do(func() {
|
||||
l, err := NewOcaAuthStatusListener(ctx)
|
||||
if err != nil {
|
||||
ocaListenerErr = err
|
||||
return
|
||||
}
|
||||
if err := l.Start(); err != nil {
|
||||
ocaListenerErr = err
|
||||
return
|
||||
}
|
||||
ocaListener = l
|
||||
})
|
||||
return ocaListener, ocaListenerErr
|
||||
}
|
||||
|
||||
// IsOCAAuthenticated returns true if the global OCA auth status is authenticated.
|
||||
// It attempts a brief wait for the first event to avoid stale reads.
|
||||
func IsOCAAuthenticated(ctx context.Context) bool {
|
||||
l, err := GetOcaAuthListener(ctx)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_ = l.WaitForFirstEvent(1 * time.Second) // best-effort
|
||||
return l.IsAuthenticated()
|
||||
}
|
||||
|
||||
// LatestState returns the last received OCA auth state (may be nil)
|
||||
func (l *OcaAuthStatusListener) LatestState() *cline.OcaAuthState {
|
||||
l.mu.RLock()
|
||||
defer l.mu.RUnlock()
|
||||
return l.lastState
|
||||
}
|
||||
|
||||
// GetLatestOCAState returns the latest known OCA auth state, optionally waiting for the first event
|
||||
func GetLatestOCAState(ctx context.Context, timeout time.Duration) (*cline.OcaAuthState, error) {
|
||||
l, err := GetOcaAuthListener(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if timeout > 0 {
|
||||
if err := l.WaitForFirstEvent(timeout); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return l.LatestState(), nil
|
||||
}
|
||||
|
||||
// ensureOcaAuthenticated initiates OCA login (if needed) and waits for success using the singleton listener
|
||||
func ensureOcaAuthenticated(ctx context.Context) error {
|
||||
// Ensure listener exists
|
||||
listener, err := GetOcaAuthListener(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize OCA auth listener: %w", err)
|
||||
}
|
||||
|
||||
// Briefly wait for first event to know current state
|
||||
_ = listener.WaitForFirstEvent(1 * time.Second)
|
||||
|
||||
// If already authenticated, nothing to do
|
||||
if listener.IsAuthenticated() {
|
||||
fmt.Println("✓ OCA authentication already active.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create gRPC client for initiating login
|
||||
client, err := global.GetDefaultClient(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to obtain client: %w", err)
|
||||
}
|
||||
|
||||
// Start login and wait for authentication
|
||||
waitCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
// Initiate login (opens the browser with a callback URL from Cline Core)
|
||||
response, err := client.Ocaaccount.OcaAccountLoginClicked(waitCtx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initiate OCA login: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("\nOpening browser for OCA authentication...")
|
||||
if response != nil && response.Value != "" {
|
||||
fmt.Printf("If the browser doesn't open automatically, visit this URL:\n%s\n\n", response.Value)
|
||||
}
|
||||
fmt.Println("Waiting for you to complete OCA authentication in your browser...")
|
||||
fmt.Println("(This may take a few moments. Timeout: 5 minutes)")
|
||||
|
||||
// Block until authenticated or timeout
|
||||
if err := listener.WaitForAuthentication(5 * time.Minute); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("✓ OCA authentication successful!")
|
||||
return nil
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
package clerror
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ClineErrorType represents the category of error
|
||||
type ClineErrorType string
|
||||
|
||||
const (
|
||||
ErrorTypeAuth ClineErrorType = "auth"
|
||||
ErrorTypeNetwork ClineErrorType = "network"
|
||||
ErrorTypeRateLimit ClineErrorType = "rateLimit"
|
||||
ErrorTypeBalance ClineErrorType = "balance"
|
||||
ErrorTypeUnknown ClineErrorType = "unknown"
|
||||
)
|
||||
|
||||
// ClineError represents a parsed error from Cline API
|
||||
type ClineError struct {
|
||||
Message string `json:"message"`
|
||||
Status int `json:"status"`
|
||||
RequestID string `json:"request_id"`
|
||||
Code interface{} `json:"code"` // Can be string or int
|
||||
ModelID string `json:"modelId"`
|
||||
ProviderID string `json:"providerId"`
|
||||
Details map[string]interface{} `json:"details"`
|
||||
}
|
||||
|
||||
// GetCodeString returns the code as a string regardless of its type
|
||||
func (e *ClineError) GetCodeString() string {
|
||||
if e == nil || e.Code == nil {
|
||||
return ""
|
||||
}
|
||||
switch v := e.Code.(type) {
|
||||
case string:
|
||||
return v
|
||||
case float64:
|
||||
return fmt.Sprintf("%.0f", v)
|
||||
case int:
|
||||
return fmt.Sprintf("%d", v)
|
||||
default:
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// Rate limit patterns from webview
|
||||
var rateLimitPatterns = []string{
|
||||
"status code 429",
|
||||
"rate limit",
|
||||
"too many requests",
|
||||
"quota exceeded",
|
||||
"resource exhausted",
|
||||
}
|
||||
|
||||
// ParseClineError parses a JSON error string into a ClineError
|
||||
func ParseClineError(errorJSON string) (*ClineError, error) {
|
||||
if errorJSON == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var err ClineError
|
||||
if parseErr := json.Unmarshal([]byte(errorJSON), &err); parseErr != nil {
|
||||
// If JSON parsing fails, create a simple error with the message
|
||||
return &ClineError{
|
||||
Message: errorJSON,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &err, nil
|
||||
}
|
||||
|
||||
// GetErrorType determines the type of error based on code, status, and message
|
||||
func (e *ClineError) GetErrorType() ClineErrorType {
|
||||
if e == nil {
|
||||
return ErrorTypeUnknown
|
||||
}
|
||||
|
||||
// Check balance error first (most specific)
|
||||
codeStr := e.GetCodeString()
|
||||
if codeStr == "insufficient_credits" {
|
||||
return ErrorTypeBalance
|
||||
}
|
||||
|
||||
// Check auth errors
|
||||
if codeStr == "ERR_BAD_REQUEST" || e.Status == 401 {
|
||||
return ErrorTypeAuth
|
||||
}
|
||||
|
||||
// Check for auth message
|
||||
if strings.Contains(e.Message, "Authentication required") ||
|
||||
strings.Contains(e.Message, "Invalid API key") ||
|
||||
strings.Contains(e.Message, "Unauthorized") {
|
||||
return ErrorTypeAuth
|
||||
}
|
||||
|
||||
// Check rate limit patterns
|
||||
messageLower := strings.ToLower(e.Message)
|
||||
for _, pattern := range rateLimitPatterns {
|
||||
if strings.Contains(messageLower, pattern) {
|
||||
return ErrorTypeRateLimit
|
||||
}
|
||||
}
|
||||
|
||||
return ErrorTypeUnknown
|
||||
}
|
||||
|
||||
// IsBalanceError returns true if this is a balance/credits error
|
||||
func (e *ClineError) IsBalanceError() bool {
|
||||
return e.GetErrorType() == ErrorTypeBalance
|
||||
}
|
||||
|
||||
// IsAuthError returns true if this is an authentication error
|
||||
func (e *ClineError) IsAuthError() bool {
|
||||
return e.GetErrorType() == ErrorTypeAuth
|
||||
}
|
||||
|
||||
// IsRateLimitError returns true if this is a rate limit error
|
||||
func (e *ClineError) IsRateLimitError() bool {
|
||||
return e.GetErrorType() == ErrorTypeRateLimit
|
||||
}
|
||||
|
||||
// GetCurrentBalance returns the current balance if available
|
||||
func (e *ClineError) GetCurrentBalance() *float64 {
|
||||
if e == nil || e.Details == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if balance, ok := e.Details["current_balance"].(float64); ok {
|
||||
return &balance
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetBuyCreditsURL returns the URL to buy credits if available
|
||||
func (e *ClineError) GetBuyCreditsURL() string {
|
||||
if e == nil || e.Details == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if url, ok := e.Details["buy_credits_url"].(string); ok {
|
||||
return url
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetTotalSpent returns the total spent amount if available
|
||||
func (e *ClineError) GetTotalSpent() *float64 {
|
||||
if e == nil || e.Details == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if spent, ok := e.Details["total_spent"].(float64); ok {
|
||||
return &spent
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetTotalPromotions returns the total promotions amount if available
|
||||
func (e *ClineError) GetTotalPromotions() *float64 {
|
||||
if e == nil || e.Details == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if promos, ok := e.Details["total_promotions"].(float64); ok {
|
||||
return &promos
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDetailMessage returns the detail message from error.details if available
|
||||
func (e *ClineError) GetDetailMessage() string {
|
||||
if e == nil || e.Details == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if msg, ok := e.Details["message"].(string); ok {
|
||||
return msg
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/config"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/task"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var configManager *config.Manager
|
||||
|
||||
func ensureConfigManager(ctx context.Context, address string) error {
|
||||
if configManager == nil || (address != "" && configManager.GetCurrentInstance() != address) {
|
||||
var err error
|
||||
var instanceAddress string
|
||||
|
||||
if address != "" {
|
||||
// Ensure instance exists at the specified address
|
||||
if err := ensureInstanceAtAddress(ctx, address); err != nil {
|
||||
return fmt.Errorf("failed to ensure instance at address %s: %w", address, err)
|
||||
}
|
||||
configManager, err = config.NewManager(ctx, address)
|
||||
instanceAddress = address
|
||||
} else {
|
||||
// Ensure default instance exists
|
||||
if err := global.EnsureDefaultInstance(ctx); err != nil {
|
||||
return fmt.Errorf("failed to ensure default instance: %w", err)
|
||||
}
|
||||
configManager, err = config.NewManager(ctx, "")
|
||||
if err == nil {
|
||||
instanceAddress = configManager.GetCurrentInstance()
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create config manager: %w", err)
|
||||
}
|
||||
|
||||
// Always set the instance we're using as the default
|
||||
registry := global.Clients.GetRegistry()
|
||||
if err := registry.SetDefaultInstance(instanceAddress); err != nil {
|
||||
// Log warning but don't fail - this is not critical
|
||||
fmt.Printf("Warning: failed to set default instance: %v\n", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewConfigCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "config",
|
||||
Aliases: []string{"c"},
|
||||
Short: "Manage Cline configuration",
|
||||
Long: `Set and manage global Cline configuration variables.`,
|
||||
}
|
||||
|
||||
cmd.AddCommand(newConfigListCommand())
|
||||
cmd.AddCommand(newConfigGetCommand())
|
||||
cmd.AddCommand(setCommand())
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newConfigGetCommand() *cobra.Command {
|
||||
var address string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "get <key>",
|
||||
Aliases: []string{"g"},
|
||||
Short: "Get a specific configuration value",
|
||||
Long: `Get the value of a specific configuration setting. Supports nested keys using dot notation (e.g., auto-approval-settings.actions.read-files).`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
key := args[0]
|
||||
|
||||
// Ensure config manager
|
||||
if err := ensureConfigManager(ctx, address); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the setting
|
||||
return configManager.GetSetting(ctx, key)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newConfigListCommand() *cobra.Command {
|
||||
var address string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Aliases: []string{"l"},
|
||||
Short: "List all configuration settings",
|
||||
Long: `List all configuration settings from the Cline instance.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
// Ensure config manager
|
||||
if err := ensureConfigManager(ctx, address); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// List settings
|
||||
return configManager.ListSettings(ctx)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func setCommand() *cobra.Command {
|
||||
var address string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "set <key=value> [key=value...]",
|
||||
Aliases: []string{"s"},
|
||||
Short: "Set configuration variables",
|
||||
Long: `Set one or more global configuration variables using key=value format.
|
||||
|
||||
This command merges the provided settings with existing values, preserving
|
||||
unspecified fields. Only the fields you explicitly set will be updated.`,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
// Parse using existing task parser
|
||||
settings, secrets, err := task.ParseTaskSettings(args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse settings: %w", err)
|
||||
}
|
||||
|
||||
// Ensure config manager
|
||||
if err := ensureConfigManager(ctx, address); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update settings (server-side merge handles preserving existing values)
|
||||
return configManager.UpdateSettings(ctx, settings, secrets)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
|
||||
return cmd
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/grpc-go/client"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
client *client.ClineClient
|
||||
clientAddress string
|
||||
}
|
||||
|
||||
func NewManager(ctx context.Context, address string) (*Manager, error) {
|
||||
var c *client.ClineClient
|
||||
var err error
|
||||
|
||||
if address != "" {
|
||||
c, err = global.GetClientForAddress(ctx, address)
|
||||
} else {
|
||||
c, err = global.GetDefaultClient(ctx)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get client: %w", err)
|
||||
}
|
||||
|
||||
// Get the actual address being used
|
||||
clientAddress := address
|
||||
if address == "" && global.Clients != nil {
|
||||
clientAddress = global.Clients.GetRegistry().GetDefaultInstance()
|
||||
}
|
||||
|
||||
return &Manager{
|
||||
client: c,
|
||||
clientAddress: clientAddress,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetCurrentInstance returns the address of the current instance
|
||||
func (m *Manager) GetCurrentInstance() string {
|
||||
return m.clientAddress
|
||||
}
|
||||
|
||||
func (m *Manager) UpdateSettings(ctx context.Context, settings *cline.Settings, secrets *cline.Secrets) error {
|
||||
request := &cline.UpdateSettingsRequestCli{
|
||||
Metadata: &cline.Metadata{},
|
||||
Settings: settings,
|
||||
Secrets: secrets,
|
||||
}
|
||||
|
||||
// Call the updateSettingsCli RPC
|
||||
_, err := m.client.State.UpdateSettingsCli(ctx, request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update settings: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Settings updated successfully")
|
||||
fmt.Printf("Instance: %s\n", m.clientAddress)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) GetState(ctx context.Context) (map[string]interface{}, error) {
|
||||
state, err := m.client.State.GetLatestState(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get state: %w", err)
|
||||
}
|
||||
|
||||
var stateData map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(state.StateJson), &stateData); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse state: %w", err)
|
||||
}
|
||||
|
||||
return stateData, nil
|
||||
}
|
||||
|
||||
func (m *Manager) ListSettings(ctx context.Context) error {
|
||||
// Get state
|
||||
stateData, err := m.GetState(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Subset of fields we will print the values for
|
||||
settingsFields := []string{
|
||||
"apiConfiguration",
|
||||
"telemetrySetting",
|
||||
"planActSeparateModelsSetting",
|
||||
"enableCheckpointsSetting",
|
||||
"mcpMarketplaceEnabled",
|
||||
"shellIntegrationTimeout",
|
||||
"terminalReuseEnabled",
|
||||
"mcpResponsesCollapsed",
|
||||
"mcpDisplayMode",
|
||||
"terminalOutputLineLimit",
|
||||
"mode",
|
||||
"preferredLanguage",
|
||||
"openaiReasoningEffort",
|
||||
"strictPlanModeEnabled",
|
||||
"focusChainSettings",
|
||||
"useAutoCondense",
|
||||
"customPrompt",
|
||||
"browserSettings",
|
||||
"defaultTerminalProfile",
|
||||
"yoloModeToggled",
|
||||
"dictationSettings",
|
||||
"autoCondenseThreshold",
|
||||
"autoApprovalSettings",
|
||||
}
|
||||
|
||||
// Render each field using the renderer
|
||||
for _, field := range settingsFields {
|
||||
if value, ok := stateData[field]; ok {
|
||||
if err := RenderField(field, value, true); err != nil {
|
||||
fmt.Printf("Error rendering %s: %v\n", field, err)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) GetSetting(ctx context.Context, key string) error {
|
||||
// Get state
|
||||
stateData, err := m.GetState(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Convert kebab-case to camelCase path
|
||||
parts := kebabToCamelPath(key)
|
||||
rootField := parts[0]
|
||||
|
||||
// Get the value
|
||||
value, found := getNestedValue(stateData, parts)
|
||||
if !found {
|
||||
return fmt.Errorf("setting '%s' not found", key)
|
||||
}
|
||||
|
||||
// Render the value
|
||||
if len(parts) == 1 {
|
||||
// Top-level field: use RenderField for nice formatting
|
||||
return RenderField(rootField, value, false)
|
||||
} else {
|
||||
// Nested field: simple print
|
||||
fmt.Printf("%s: %s\n", key, formatValue(value, rootField, true))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// kebabToCamelPath converts a kebab-case path to camelCase
|
||||
// e.g., "auto-approval-settings.actions.read-files" -> "autoApprovalSettings.actions.readFiles"
|
||||
func kebabToCamelPath(path string) []string {
|
||||
parts := strings.Split(path, ".")
|
||||
for i, part := range parts {
|
||||
parts[i] = kebabToCamel(part)
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
// kebabToCamel converts a single kebab-case string to camelCase
|
||||
// e.g., "auto-approval-settings" -> "autoApprovalSettings"
|
||||
func kebabToCamel(s string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
|
||||
parts := strings.Split(s, "-")
|
||||
if len(parts) == 1 {
|
||||
return s
|
||||
}
|
||||
|
||||
// First part stays lowercase, rest are capitalized
|
||||
result := parts[0]
|
||||
for i := 1; i < len(parts); i++ {
|
||||
if parts[i] != "" {
|
||||
result += strings.ToUpper(parts[i][:1]) + parts[i][1:]
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// getNestedValue retrieves a value from a nested map using dot notation
|
||||
// e.g., "autoApprovalSettings.actions.readFiles"
|
||||
func getNestedValue(data map[string]interface{}, parts []string) (interface{}, bool) {
|
||||
current := interface{}(data)
|
||||
|
||||
for _, part := range parts {
|
||||
// Try to access as map
|
||||
if m, ok := current.(map[string]interface{}); ok {
|
||||
if val, exists := m[part]; exists {
|
||||
current = val
|
||||
continue
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return current, true
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// sensitiveKeywords defines field name patterns that should be censored
|
||||
var sensitiveKeywords = []string{"key", "secret", "password", "cline-account-id"}
|
||||
|
||||
// camelToKebab converts camelCase to kebab-case
|
||||
// e.g., "autoApprovalSettings" -> "auto-approval-settings"
|
||||
func camelToKebab(s string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
|
||||
var result []rune
|
||||
for i, r := range s {
|
||||
if i > 0 && r >= 'A' && r <= 'Z' {
|
||||
result = append(result, '-')
|
||||
}
|
||||
result = append(result, r|32) // Convert to lowercase (works for A-Z)
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
|
||||
// isSensitiveField checks if a field name contains sensitive keywords
|
||||
func isSensitiveField(fieldName string) bool {
|
||||
if fieldName == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
lowerName := strings.ToLower(fieldName)
|
||||
for _, keyword := range sensitiveKeywords {
|
||||
if strings.Contains(lowerName, keyword) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// formatValue formats a value for display, handling empty strings and censoring sensitive fields
|
||||
func formatValue(val interface{}, fieldName string, censor bool) string {
|
||||
// Handle empty strings specifically
|
||||
if str, ok := val.(string); ok && str == "" {
|
||||
return "''"
|
||||
}
|
||||
|
||||
if censor && isSensitiveField(fieldName) {
|
||||
valStr := fmt.Sprintf("%v", val)
|
||||
if valStr != "" && valStr != "''" {
|
||||
return "********"
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%v", val)
|
||||
}
|
||||
|
||||
// RenderField renders a single config field with proper formatting
|
||||
func RenderField(key string, value interface{}, censor bool) error {
|
||||
switch key {
|
||||
// Nested objects - render with header + nested fields
|
||||
case "apiConfiguration":
|
||||
return renderApiConfiguration(value, censor)
|
||||
case "browserSettings":
|
||||
return renderBrowserSettings(value, censor)
|
||||
case "focusChainSettings":
|
||||
return renderFocusChainSettings(value, censor)
|
||||
case "dictationSettings":
|
||||
return renderDictationSettings(value, censor)
|
||||
case "autoApprovalSettings":
|
||||
return renderAutoApprovalSettings(value, censor)
|
||||
|
||||
// Simple values - just print key: value
|
||||
case "mode", "telemetrySetting", "preferredLanguage", "customPrompt",
|
||||
"defaultTerminalProfile", "mcpDisplayMode", "openaiReasoningEffort",
|
||||
"planActSeparateModelsSetting", "enableCheckpointsSetting",
|
||||
"mcpMarketplaceEnabled", "terminalReuseEnabled",
|
||||
"mcpResponsesCollapsed", "strictPlanModeEnabled",
|
||||
"useAutoCondense", "yoloModeToggled", "shellIntegrationTimeout",
|
||||
"terminalOutputLineLimit", "autoCondenseThreshold":
|
||||
fmt.Printf("%s: %s\n", camelToKebab(key), formatValue(value, key, censor))
|
||||
return nil
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unknown config field: %s", key)
|
||||
}
|
||||
}
|
||||
|
||||
// renderApiConfiguration renders the API configuration object
|
||||
func renderApiConfiguration(value interface{}, censor bool) error {
|
||||
fmt.Println("api-configuration:")
|
||||
|
||||
configMap, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid api-configuration format")
|
||||
}
|
||||
|
||||
// Print each field directly
|
||||
for key, val := range configMap {
|
||||
fmt.Printf(" %s: %s\n", camelToKebab(key), formatValue(val, key, censor))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderBrowserSettings renders browser settings
|
||||
func renderBrowserSettings(value interface{}, censor bool) error {
|
||||
fmt.Println("browser-settings:")
|
||||
|
||||
settingsMap, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid browser-settings format")
|
||||
}
|
||||
|
||||
// Handle nested viewport if present
|
||||
if viewport, ok := settingsMap["viewport"].(map[string]interface{}); ok {
|
||||
fmt.Println(" viewport:")
|
||||
for key, val := range viewport {
|
||||
fmt.Printf(" %s: %s\n", camelToKebab(key), formatValue(val, key, censor))
|
||||
}
|
||||
}
|
||||
|
||||
// Print other fields
|
||||
for key, val := range settingsMap {
|
||||
if key != "viewport" {
|
||||
fmt.Printf(" %s: %s\n", camelToKebab(key), formatValue(val, key, censor))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderFocusChainSettings renders focus chain settings
|
||||
func renderFocusChainSettings(value interface{}, censor bool) error {
|
||||
fmt.Println("focus-chain-settings:")
|
||||
|
||||
settingsMap, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid focus-chain-settings format")
|
||||
}
|
||||
|
||||
for key, val := range settingsMap {
|
||||
fmt.Printf(" %s: %s\n", camelToKebab(key), formatValue(val, key, censor))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderDictationSettings renders dictation settings
|
||||
func renderDictationSettings(value interface{}, censor bool) error {
|
||||
fmt.Println("dictation-settings:")
|
||||
|
||||
settingsMap, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid dictation-settings format")
|
||||
}
|
||||
|
||||
for key, val := range settingsMap {
|
||||
fmt.Printf(" %s: %s\n", camelToKebab(key), formatValue(val, key, censor))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderAutoApprovalSettings renders auto approval settings
|
||||
func renderAutoApprovalSettings(value interface{}, censor bool) error {
|
||||
fmt.Println("auto-approval-settings:")
|
||||
|
||||
settingsMap, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid auto-approval-settings format")
|
||||
}
|
||||
|
||||
// Print top-level fields (skip version, handle actions specially)
|
||||
for key, val := range settingsMap {
|
||||
if key == "version" {
|
||||
continue // Skip version
|
||||
}
|
||||
|
||||
if key == "actions" {
|
||||
// Handle nested actions with double indentation
|
||||
fmt.Println(" actions:")
|
||||
if actionsMap, ok := val.(map[string]interface{}); ok {
|
||||
for actionKey, actionVal := range actionsMap {
|
||||
fmt.Printf(" %s: %s\n", camelToKebab(actionKey), formatValue(actionVal, actionKey, censor))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Print other fields normally (enabled, enableNotifications, favorites)
|
||||
fmt.Printf(" %s: %s\n", camelToKebab(key), formatValue(val, key, censor))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
func isTTY() bool {
|
||||
return term.IsTerminal(int(os.Stdout.Fd()))
|
||||
}
|
||||
|
||||
func ClearLine() {
|
||||
if !isTTY() {
|
||||
return
|
||||
}
|
||||
fmt.Print("\r\033[K")
|
||||
}
|
||||
|
||||
// ClearToEnd clears from cursor to end of screen
|
||||
func ClearToEnd() {
|
||||
if !isTTY() {
|
||||
return
|
||||
}
|
||||
fmt.Print("\033[J")
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// BannerInfo contains information to display in the session banner
|
||||
type BannerInfo struct {
|
||||
Version string
|
||||
Provider string
|
||||
ModelID string
|
||||
Workdir string
|
||||
Mode string
|
||||
}
|
||||
|
||||
// RenderSessionBanner renders a nice banner showing version, model, and workspace info
|
||||
func RenderSessionBanner(info BannerInfo) string {
|
||||
// Bright white for title
|
||||
titleStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("15")). // Bright white
|
||||
Bold(true)
|
||||
|
||||
// Dim gray for regular text (same as huh placeholder)
|
||||
dimStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.AdaptiveColor{Light: "248", Dark: "238"})
|
||||
|
||||
// Border color matches mode
|
||||
borderColor := lipgloss.Color("3") // Yellow for plan
|
||||
if info.Mode == "act" {
|
||||
borderColor = lipgloss.Color("39") // Blue for act
|
||||
}
|
||||
|
||||
boxStyle := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(borderColor).
|
||||
Padding(1, 4)
|
||||
|
||||
var lines []string
|
||||
|
||||
// Format version with "v" prefix if it starts with a number
|
||||
versionStr := info.Version
|
||||
if len(versionStr) > 0 && versionStr[0] >= '0' && versionStr[0] <= '9' {
|
||||
versionStr = "v" + versionStr
|
||||
}
|
||||
|
||||
// First line: "cline cli vX.X.X" on left, "plan mode" on right
|
||||
leftSide := titleStyle.Render("cline cli preview") + " " + dimStyle.Render(versionStr)
|
||||
|
||||
if info.Mode != "" {
|
||||
modeColor := lipgloss.Color("3") // Yellow for plan
|
||||
if info.Mode == "act" {
|
||||
modeColor = lipgloss.Color("39") // Blue for act
|
||||
}
|
||||
modeStyle := lipgloss.NewStyle().Foreground(modeColor).Bold(true)
|
||||
rightSide := modeStyle.Render(info.Mode + " mode")
|
||||
|
||||
// Calculate spacing to push mode to the right
|
||||
// Assume a reasonable width (we'll adjust based on content)
|
||||
lineWidth := 50
|
||||
leftWidth := lipgloss.Width(leftSide)
|
||||
rightWidth := lipgloss.Width(rightSide)
|
||||
spacing := lineWidth - leftWidth - rightWidth
|
||||
|
||||
if spacing > 0 {
|
||||
titleLine := leftSide + strings.Repeat(" ", spacing) + rightSide
|
||||
lines = append(lines, titleLine)
|
||||
} else {
|
||||
// If too narrow, just put them on same line with a space
|
||||
lines = append(lines, leftSide+" "+rightSide)
|
||||
}
|
||||
} else {
|
||||
// No mode, just show title
|
||||
lines = append(lines, leftSide)
|
||||
}
|
||||
|
||||
// Model line - dim gray
|
||||
if info.Provider != "" && info.ModelID != "" {
|
||||
lines = append(lines, dimStyle.Render(info.Provider+"/"+shortenPath(info.ModelID, 30)))
|
||||
}
|
||||
|
||||
// Workspace line - dim gray
|
||||
if info.Workdir != "" {
|
||||
lines = append(lines, dimStyle.Render(shortenPath(info.Workdir, 45)))
|
||||
}
|
||||
|
||||
content := lipgloss.JoinVertical(lipgloss.Left, lines...)
|
||||
return boxStyle.Render(content)
|
||||
}
|
||||
|
||||
// shortenPath shortens a filesystem path to fit within maxLen
|
||||
func shortenPath(path string, maxLen int) string {
|
||||
// Try to replace home directory with ~ (cross-platform)
|
||||
if homeDir, err := os.UserHomeDir(); err == nil {
|
||||
if strings.HasPrefix(path, homeDir) {
|
||||
shortened := "~" + path[len(homeDir):]
|
||||
// Always use ~ version if we can
|
||||
path = shortened
|
||||
}
|
||||
}
|
||||
|
||||
if len(path) <= maxLen {
|
||||
return path
|
||||
}
|
||||
|
||||
// If still too long, show last few path components
|
||||
if len(path) > maxLen {
|
||||
parts := strings.Split(path, string(filepath.Separator))
|
||||
if len(parts) > 2 {
|
||||
// Show last 2-3 components
|
||||
lastParts := parts[len(parts)-2:]
|
||||
shortened := "..." + string(filepath.Separator) + strings.Join(lastParts, string(filepath.Separator))
|
||||
if len(shortened) <= maxLen {
|
||||
return shortened
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: truncate with ellipsis
|
||||
if len(path) > maxLen {
|
||||
return "..." + path[len(path)-maxLen+3:]
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
// ExtractBannerInfoFromState extracts banner info from state JSON
|
||||
func ExtractBannerInfoFromState(stateJSON, version string) (BannerInfo, error) {
|
||||
var state map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(stateJSON), &state); err != nil {
|
||||
return BannerInfo{}, fmt.Errorf("failed to parse state JSON: %w", err)
|
||||
}
|
||||
|
||||
info := BannerInfo{
|
||||
Version: version,
|
||||
}
|
||||
|
||||
// Extract mode
|
||||
if mode, ok := state["mode"].(string); ok {
|
||||
info.Mode = mode
|
||||
}
|
||||
|
||||
// Extract workspace roots
|
||||
if workspaceRoots, ok := state["workspaceRoots"].([]interface{}); ok && len(workspaceRoots) > 0 {
|
||||
if root, ok := workspaceRoots[0].(map[string]interface{}); ok {
|
||||
if path, ok := root["path"].(string); ok {
|
||||
info.Workdir = path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract API configuration to get provider/model
|
||||
if apiConfig, ok := state["apiConfiguration"].(map[string]interface{}); ok {
|
||||
// Try common keys for provider and model (both camelCase and lowercase variants)
|
||||
providerKeys := []string{"apiProvider", "api_provider"}
|
||||
modelKeys := []string{"apiModelId", "api_model_id"}
|
||||
|
||||
// Try to extract provider
|
||||
for _, key := range providerKeys {
|
||||
if provider, ok := apiConfig[key].(string); ok && provider != "" {
|
||||
info.Provider = provider
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Try to extract model ID
|
||||
for _, key := range modelKeys {
|
||||
if modelID, ok := apiConfig[key].(string); ok && modelID != "" {
|
||||
info.ModelID = shortenModelID(modelID)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// shortenModelID shortens long model IDs for display
|
||||
func shortenModelID(modelID string) string {
|
||||
// Remove date suffixes only if they're at the end (e.g., -20241022)
|
||||
// Check if the model ID ends with -YYYYMMDD pattern
|
||||
if len(modelID) > 9 {
|
||||
suffix := modelID[len(modelID)-9:] // Last 9 chars: -20241022
|
||||
if suffix[0] == '-' &&
|
||||
(strings.HasPrefix(suffix[1:], "202") || strings.HasPrefix(suffix[1:], "201")) {
|
||||
// Verify all remaining chars are digits
|
||||
allDigits := true
|
||||
for _, c := range suffix[1:] {
|
||||
if c < '0' || c > '9' {
|
||||
allDigits = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allDigits {
|
||||
return modelID[:len(modelID)-9]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If still too long, show first 40 chars
|
||||
if len(modelID) > 40 {
|
||||
return modelID[:37] + "..."
|
||||
}
|
||||
|
||||
return modelID
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
)
|
||||
|
||||
// MessageDeduplicator handles message deduplication to prevent duplicate displays
|
||||
type MessageDeduplicator struct {
|
||||
mu sync.RWMutex
|
||||
seenMessages map[string]time.Time
|
||||
maxAge time.Duration
|
||||
cleanupTicker *time.Ticker
|
||||
}
|
||||
|
||||
// NewMessageDeduplicator creates a new message deduplicator
|
||||
func NewMessageDeduplicator() *MessageDeduplicator {
|
||||
d := &MessageDeduplicator{
|
||||
seenMessages: make(map[string]time.Time),
|
||||
maxAge: 5 * time.Minute, // Keep messages for 5 minutes
|
||||
cleanupTicker: time.NewTicker(1 * time.Minute), // Cleanup every minute
|
||||
}
|
||||
|
||||
// Start cleanup goroutine
|
||||
go d.cleanup()
|
||||
|
||||
return d
|
||||
}
|
||||
|
||||
// IsDuplicate checks if a message is a duplicate
|
||||
func (d *MessageDeduplicator) IsDuplicate(msg *types.ClineMessage) bool {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
// Create a hash of the message content
|
||||
hash := d.hashMessage(msg)
|
||||
|
||||
// Check if we've seen this message recently
|
||||
if lastSeen, exists := d.seenMessages[hash]; exists {
|
||||
// If we've seen it within the last few seconds, it's a duplicate
|
||||
if time.Since(lastSeen) < 2*time.Second {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Mark this message as seen
|
||||
d.seenMessages[hash] = time.Now()
|
||||
return false
|
||||
}
|
||||
|
||||
// hashMessage creates a hash of the message for deduplication
|
||||
func (d *MessageDeduplicator) hashMessage(msg *types.ClineMessage) string {
|
||||
// Create a hash based on message content, type, and timestamp
|
||||
content := fmt.Sprintf("%s|%s|%s|%d",
|
||||
string(msg.Type),
|
||||
msg.Say,
|
||||
msg.Ask,
|
||||
msg.Timestamp)
|
||||
|
||||
// For partial messages, include the text content in the hash
|
||||
if msg.Partial {
|
||||
content += "|" + msg.Text
|
||||
}
|
||||
|
||||
hash := md5.Sum([]byte(content))
|
||||
return fmt.Sprintf("%x", hash)
|
||||
}
|
||||
|
||||
// cleanup removes old entries from the seen messages map
|
||||
func (d *MessageDeduplicator) cleanup() {
|
||||
for range d.cleanupTicker.C {
|
||||
d.mu.Lock()
|
||||
now := time.Now()
|
||||
|
||||
// Remove entries older than maxAge
|
||||
for hash, timestamp := range d.seenMessages {
|
||||
if now.Sub(timestamp) > d.maxAge {
|
||||
delete(d.seenMessages, hash)
|
||||
}
|
||||
}
|
||||
|
||||
d.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Stop stops the cleanup goroutine
|
||||
func (d *MessageDeduplicator) Stop() {
|
||||
if d.cleanupTicker != nil {
|
||||
d.cleanupTicker.Stop()
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"fmt"
|
||||
|
||||
"github.com/charmbracelet/glamour"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
type MarkdownRenderer struct {
|
||||
renderer *glamour.TermRenderer
|
||||
width int
|
||||
}
|
||||
|
||||
// i went back and forth on whether or not to enable word wrap
|
||||
// setting line width to 0 enables the terminal to handle wrapping
|
||||
// setting it to a terminal width enables glamour's word wrap
|
||||
// the thing is, glamour's nice indentation looks really good, and
|
||||
// won't work without glamour's word wrap - if you use the terminal's
|
||||
// word wrap, the indentation looks weird so you have to turn it off
|
||||
// and everything will be right next to the left margin
|
||||
// but if you DO use glamours word wrap, it also means if you resize the terminal,
|
||||
// it will scuff everything. but given that this is the case for the input anyway,
|
||||
// i figure we just make things as beautiful as possible
|
||||
// and if you resize the terminal, you'll learn real quick.
|
||||
// anyway, you can set this to true or false to experiment
|
||||
const USETERMINALWORDWRAP = true
|
||||
|
||||
|
||||
// seems like a reliable way to check for terminals
|
||||
// for now i'm keeping everything as auto
|
||||
// eventually we can define a custom glamour style for ghostty / iterm
|
||||
// https://github.com/charmbracelet/glamour/blob/master/styles/README.md)
|
||||
func detectTerminalTheme() string {
|
||||
switch os.Getenv("TERM_PROGRAM") {
|
||||
case "iTerm.app", "Ghostty":
|
||||
return "dark"
|
||||
}
|
||||
if os.Getenv("GHOSTTY_VERSION") != "" {
|
||||
return "dark"
|
||||
}
|
||||
return "dark"
|
||||
}
|
||||
|
||||
func glamourStyleJSON(terminalWrap bool) string {
|
||||
const tmpl = `{
|
||||
"document": {
|
||||
"block_prefix": "\n",
|
||||
"block_suffix": "\n",
|
||||
"color": "252",
|
||||
"margin": %s
|
||||
},
|
||||
"code_block": {
|
||||
"margin": 0
|
||||
}
|
||||
}`
|
||||
if terminalWrap {
|
||||
return fmt.Sprintf(tmpl, "0")
|
||||
}
|
||||
return fmt.Sprintf(tmpl, "2")
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
func NewMarkdownRenderer() (*MarkdownRenderer, error) {
|
||||
var wordWrap int
|
||||
if USETERMINALWORDWRAP {
|
||||
// terminal handles wrapping -> disable glamour wrap
|
||||
wordWrap = 0
|
||||
} else {
|
||||
// glamour handles wrapping -> set to current width
|
||||
wordWrap = terminalWidthOr(0)
|
||||
}
|
||||
|
||||
r, err := glamour.NewTermRenderer(
|
||||
glamour.WithStandardStyle(detectTerminalTheme()), // Load full auto style first
|
||||
glamour.WithStylesFromJSONBytes([]byte(glamourStyleJSON(USETERMINALWORDWRAP))), // Then override just margins
|
||||
glamour.WithWordWrap(wordWrap),
|
||||
glamour.WithPreservedNewLines(),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &MarkdownRenderer{
|
||||
renderer: r,
|
||||
width: 0, // Unlimited width
|
||||
}, nil
|
||||
}
|
||||
|
||||
// terminalWidthOr returns the terminal width or the provided fallback.
|
||||
// It first tries term.GetSize, then falls back to $COLUMNS if set.
|
||||
func terminalWidthOr(fallback int) int {
|
||||
if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && w > 0 {
|
||||
return w
|
||||
}
|
||||
if cols := os.Getenv("COLUMNS"); cols != "" {
|
||||
if n, err := strconv.Atoi(cols); err == nil && n > 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// NewMarkdownRendererWithWidth creates a markdown renderer with a specific width.
|
||||
// Useful for tables and other content that should fit within terminal bounds.
|
||||
func NewMarkdownRendererWithWidth(width int) (*MarkdownRenderer, error) {
|
||||
r, err := glamour.NewTermRenderer(
|
||||
glamour.WithStandardStyle(detectTerminalTheme()),
|
||||
glamour.WithStylesFromJSONBytes([]byte(glamourStyleJSON(false))),
|
||||
glamour.WithWordWrap(width),
|
||||
glamour.WithPreservedNewLines(),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &MarkdownRenderer{renderer: r, width: width}, nil
|
||||
}
|
||||
|
||||
|
||||
// NewMarkdownRendererForTerminal creates a markdown renderer using the actual terminal width.
|
||||
// Falls back to 120 if terminal width cannot be determined.
|
||||
func NewMarkdownRendererForTerminal() (*MarkdownRenderer, error) {
|
||||
width := terminalWidthOr(120)
|
||||
return NewMarkdownRendererWithWidth(width)
|
||||
}
|
||||
|
||||
func (mr *MarkdownRenderer) Render(markdown string) (string, error) {
|
||||
rendered, err := mr.renderer.Render(markdown)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimLeft(strings.TrimRight(rendered, "\n"), "\n"), nil
|
||||
}
|
||||
@@ -1,304 +0,0 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/output"
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
type Renderer struct {
|
||||
typewriter *TypewriterPrinter
|
||||
mdRenderer *MarkdownRenderer
|
||||
outputFormat string
|
||||
|
||||
// Lipgloss styles that respect outputFormat
|
||||
dimStyle lipgloss.Style
|
||||
greenStyle lipgloss.Style
|
||||
redStyle lipgloss.Style
|
||||
yellowStyle lipgloss.Style
|
||||
blueStyle lipgloss.Style
|
||||
whiteStyle lipgloss.Style
|
||||
boldStyle lipgloss.Style
|
||||
successStyle lipgloss.Style
|
||||
}
|
||||
|
||||
func NewRenderer(outputFormat string) *Renderer {
|
||||
mdRenderer, err := NewMarkdownRenderer()
|
||||
if err != nil {
|
||||
mdRenderer = nil
|
||||
}
|
||||
|
||||
r := &Renderer{
|
||||
typewriter: NewTypewriterPrinter(DefaultTypewriterConfig()),
|
||||
mdRenderer: mdRenderer,
|
||||
outputFormat: outputFormat,
|
||||
}
|
||||
|
||||
// Initialize lipgloss styles (will respect the global color profile)
|
||||
r.dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
|
||||
r.greenStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2"))
|
||||
r.redStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1"))
|
||||
r.yellowStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3"))
|
||||
r.blueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("39"))
|
||||
r.whiteStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("7"))
|
||||
r.boldStyle = lipgloss.NewStyle().Bold(true)
|
||||
r.successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Bold(true)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *Renderer) RenderMessage(prefix, text string, newline bool) error {
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
clean := r.sanitizeText(text)
|
||||
if clean == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if newline {
|
||||
output.Printf("%s: %s\n", prefix, clean)
|
||||
} else {
|
||||
output.Printf("%s: %s", prefix, clean)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatNumber formats numbers with k/m abbreviations
|
||||
func formatNumber(n int) string {
|
||||
if n >= 1000000 {
|
||||
return fmt.Sprintf("%.1fm", float64(n)/1000000.0)
|
||||
} else if n >= 1000 {
|
||||
return fmt.Sprintf("%.1fk", float64(n)/1000.0)
|
||||
}
|
||||
return fmt.Sprintf("%d", n)
|
||||
}
|
||||
|
||||
// formatUsageInfo formats token usage information (extracted from RenderAPI)
|
||||
func (r *Renderer) formatUsageInfo(tokensIn, tokensOut, cacheReads, cacheWrites int, cost float64) string {
|
||||
parts := make([]string, 0, 4)
|
||||
|
||||
if tokensIn != 0 {
|
||||
parts = append(parts, fmt.Sprintf("↑ %s", formatNumber(tokensIn)))
|
||||
}
|
||||
if tokensOut != 0 {
|
||||
parts = append(parts, fmt.Sprintf("↓ %s", formatNumber(tokensOut)))
|
||||
}
|
||||
if cacheReads != 0 {
|
||||
parts = append(parts, fmt.Sprintf("→ %s", formatNumber(cacheReads)))
|
||||
}
|
||||
if cacheWrites != 0 {
|
||||
parts = append(parts, fmt.Sprintf("← %s", formatNumber(cacheWrites)))
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
return fmt.Sprintf("$%.4f", cost)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s $%.4f", strings.Join(parts, " "), cost)
|
||||
}
|
||||
|
||||
|
||||
func (r *Renderer) RenderAPI(status string, apiInfo *types.APIRequestInfo) error {
|
||||
if apiInfo.Cost >= 0 {
|
||||
usageInfo := r.formatUsageInfo(apiInfo.TokensIn, apiInfo.TokensOut, apiInfo.CacheReads, apiInfo.CacheWrites, apiInfo.Cost)
|
||||
markdown := fmt.Sprintf("## API %s `%s`", status, usageInfo)
|
||||
rendered := r.RenderMarkdown(markdown)
|
||||
output.Print(rendered)
|
||||
} else {
|
||||
// honestly i see no point in showing "### API processing request" here...
|
||||
// markdown := fmt.Sprintf("## API %s", status)
|
||||
// rendered := r.RenderMarkdown(markdown)
|
||||
// output.Printf("\n%s\n", rendered)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Renderer) RenderRetry(attempt, maxAttempts, delaySec int) error {
|
||||
message := fmt.Sprintf("Retrying failed attempt %d/%d", attempt, maxAttempts)
|
||||
if delaySec > 0 {
|
||||
message += fmt.Sprintf(" in %d seconds", delaySec)
|
||||
}
|
||||
message += "..."
|
||||
r.typewriter.PrintMessageLine("API INFO", message)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Renderer) RenderTaskCancelled() error {
|
||||
markdown := "## Task cancelled"
|
||||
rendered := r.RenderMarkdown(markdown)
|
||||
output.Printf("\n%s\n", rendered)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenderTaskList displays task history with improved formatting
|
||||
func (r *Renderer) RenderTaskList(tasks []*cline.TaskItem) error {
|
||||
const maxTasks = 20
|
||||
|
||||
startIndex := 0
|
||||
if len(tasks) > maxTasks {
|
||||
startIndex = len(tasks) - maxTasks
|
||||
}
|
||||
|
||||
recentTasks := tasks[startIndex:]
|
||||
|
||||
r.typewriter.PrintfLn("=== Task History (showing last %d of %d total tasks) ===\n", len(recentTasks), len(tasks))
|
||||
|
||||
for i, taskItem := range recentTasks {
|
||||
r.typewriter.PrintfLn("Task ID: %s", taskItem.Id)
|
||||
|
||||
description := taskItem.Task
|
||||
if len(description) > 1000 {
|
||||
description = description[:1000] + "..."
|
||||
}
|
||||
r.typewriter.PrintfLn("Message: %s", description)
|
||||
|
||||
usageInfo := r.formatUsageInfo(int(taskItem.TokensIn), int(taskItem.TokensOut), int(taskItem.CacheReads), int(taskItem.CacheWrites), taskItem.TotalCost)
|
||||
r.typewriter.PrintfLn("Usage : %s", usageInfo)
|
||||
|
||||
// Single space between tasks (except last)
|
||||
if i < len(recentTasks)-1 {
|
||||
r.typewriter.PrintfLn("")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Renderer) RenderDebug(format string, args ...interface{}) error {
|
||||
if global.Config.Verbose {
|
||||
message := fmt.Sprintf(format, args...)
|
||||
r.typewriter.PrintMessageLine("[DEBUG]", message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Renderer) ClearLine() {
|
||||
output.Print("\r\033[K")
|
||||
}
|
||||
|
||||
func (r *Renderer) MoveCursorUp(n int) {
|
||||
output.Printf("\033[%dA", n)
|
||||
}
|
||||
|
||||
func (r *Renderer) sanitizeText(text string) string {
|
||||
text = strings.TrimSpace(text)
|
||||
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Remove control characters and escape sequences
|
||||
var result strings.Builder
|
||||
for _, r := range text {
|
||||
// Keep printable characters, spaces, tabs, and newlines
|
||||
if r >= 32 || r == '\t' || r == '\n' || r == '\r' {
|
||||
result.WriteRune(r)
|
||||
}
|
||||
// Skip control characters (0-31 except tab, newline, carriage return)
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
func (r *Renderer) SetTypewriterEnabled(enabled bool) {
|
||||
r.typewriter.SetEnabled(enabled)
|
||||
}
|
||||
|
||||
func (r *Renderer) IsTypewriterEnabled() bool {
|
||||
return r.typewriter.IsEnabled()
|
||||
}
|
||||
|
||||
func (r *Renderer) SetTypewriterSpeed(multiplier float64) {
|
||||
r.typewriter.SetSpeed(multiplier)
|
||||
}
|
||||
|
||||
func (r *Renderer) GetTypewriter() *TypewriterPrinter {
|
||||
return r.typewriter
|
||||
}
|
||||
|
||||
func (r *Renderer) GetMdRenderer() *MarkdownRenderer {
|
||||
return r.mdRenderer
|
||||
}
|
||||
|
||||
// RenderMarkdown renders markdown text to terminal format with ANSI codes
|
||||
// Falls back to plaintext if markdown rendering is unavailable or fails
|
||||
// Respects output format - skips rendering in plain mode or non-TTY contexts
|
||||
func (r *Renderer) RenderMarkdown(markdown string) string {
|
||||
// Skip markdown rendering if:
|
||||
// 1. Output format is explicitly "plain"
|
||||
// 2. Not in a TTY (piped output, file redirect, CI, etc.)
|
||||
if r.outputFormat == "plain" || !isTTY() {
|
||||
return markdown
|
||||
}
|
||||
|
||||
if r.mdRenderer == nil {
|
||||
return markdown
|
||||
}
|
||||
|
||||
rendered, err := r.mdRenderer.Render(markdown)
|
||||
if err != nil {
|
||||
return markdown
|
||||
}
|
||||
|
||||
return rendered
|
||||
}
|
||||
|
||||
// Lipgloss-based color rendering methods
|
||||
// These automatically respect the output format via lipgloss color profile
|
||||
|
||||
// Dim renders text in dim gray (bright black)
|
||||
func (r *Renderer) Dim(text string) string {
|
||||
return r.dimStyle.Render(text)
|
||||
}
|
||||
|
||||
// Green renders text in green
|
||||
func (r *Renderer) Green(text string) string {
|
||||
return r.greenStyle.Render(text)
|
||||
}
|
||||
|
||||
// Red renders text in red
|
||||
func (r *Renderer) Red(text string) string {
|
||||
return r.redStyle.Render(text)
|
||||
}
|
||||
|
||||
// Yellow renders text in yellow
|
||||
func (r *Renderer) Yellow(text string) string {
|
||||
return r.yellowStyle.Render(text)
|
||||
}
|
||||
|
||||
// Blue renders text in 256-color blue (index 39)
|
||||
func (r *Renderer) Blue(text string) string {
|
||||
return r.blueStyle.Render(text)
|
||||
}
|
||||
|
||||
// White renders text in white
|
||||
func (r *Renderer) White(text string) string {
|
||||
return r.whiteStyle.Render(text)
|
||||
}
|
||||
|
||||
// Bold renders text in bold
|
||||
func (r *Renderer) Bold(text string) string {
|
||||
return r.boldStyle.Render(text)
|
||||
}
|
||||
|
||||
// Success renders text in green with bold
|
||||
func (r *Renderer) Success(text string) string {
|
||||
return r.successStyle.Render(text)
|
||||
}
|
||||
|
||||
// SuccessWithCheckmark renders text in green with bold and a checkmark prefix
|
||||
func (r *Renderer) SuccessWithCheckmark(text string) string {
|
||||
return r.Success("✓ " + text)
|
||||
}
|
||||
|
||||
// ErrorWithX renders text in red with an X prefix
|
||||
func (r *Renderer) ErrorWithX(text string) string {
|
||||
return r.Red("✗ " + text)
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/output"
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
)
|
||||
|
||||
type StreamingSegment struct {
|
||||
mu sync.Mutex
|
||||
sayType string
|
||||
prefix string
|
||||
buffer strings.Builder
|
||||
frozen bool
|
||||
mdRenderer *MarkdownRenderer
|
||||
toolRenderer *ToolRenderer
|
||||
shouldMarkdown bool
|
||||
outputFormat string
|
||||
msg *types.ClineMessage
|
||||
toolParser *ToolResultParser
|
||||
}
|
||||
|
||||
func NewStreamingSegment(sayType, prefix string, mdRenderer *MarkdownRenderer, shouldMarkdown bool, msg *types.ClineMessage, outputFormat string) *StreamingSegment {
|
||||
ss := &StreamingSegment{
|
||||
sayType: sayType,
|
||||
prefix: prefix,
|
||||
mdRenderer: mdRenderer,
|
||||
toolRenderer: NewToolRenderer(mdRenderer, outputFormat),
|
||||
shouldMarkdown: shouldMarkdown,
|
||||
outputFormat: outputFormat,
|
||||
msg: msg,
|
||||
toolParser: NewToolResultParser(mdRenderer),
|
||||
}
|
||||
|
||||
// Render rich header immediately when creating segment (if in rich mode and TTY)
|
||||
if shouldMarkdown && outputFormat != "plain" && isTTY() {
|
||||
header := ss.generateRichHeader()
|
||||
rendered, _ := mdRenderer.Render(header)
|
||||
output.Println("")
|
||||
output.Print(rendered)
|
||||
}
|
||||
|
||||
return ss
|
||||
}
|
||||
|
||||
func (ss *StreamingSegment) AppendText(text string) {
|
||||
ss.mu.Lock()
|
||||
defer ss.mu.Unlock()
|
||||
|
||||
if ss.frozen {
|
||||
return
|
||||
}
|
||||
|
||||
// Replace buffer with FULL text - msg.Text contains complete accumulated content
|
||||
ss.buffer.Reset()
|
||||
ss.buffer.WriteString(text)
|
||||
|
||||
// No rendering during streaming - we'll render once on Freeze()
|
||||
}
|
||||
|
||||
|
||||
func (ss *StreamingSegment) Freeze() {
|
||||
ss.mu.Lock()
|
||||
defer ss.mu.Unlock()
|
||||
|
||||
if ss.frozen {
|
||||
return
|
||||
}
|
||||
|
||||
ss.frozen = true
|
||||
currentBuffer := ss.buffer.String()
|
||||
|
||||
// Render and print the final markdown
|
||||
ss.renderFinal(currentBuffer)
|
||||
}
|
||||
|
||||
func (ss *StreamingSegment) renderFinal(currentBuffer string) {
|
||||
var bodyContent string
|
||||
|
||||
// Use ToolRenderer for all body rendering to centralize logic
|
||||
if ss.sayType == "ask" {
|
||||
// Handle ASK messages
|
||||
if ss.msg.Ask == string(types.AskTypeTool) {
|
||||
// Tool approval: use ToolRenderer for body
|
||||
var tool types.ToolMessage
|
||||
if err := json.Unmarshal([]byte(currentBuffer), &tool); err == nil {
|
||||
// For approval requests in streaming, use the preview method
|
||||
bodyContent = ss.toolRenderer.GenerateToolContentPreview(&tool)
|
||||
}
|
||||
} else if ss.msg.Ask == string(types.AskTypeFollowup) {
|
||||
// Followup question: use ToolRenderer
|
||||
bodyContent = ss.toolRenderer.GenerateAskFollowupBody(currentBuffer)
|
||||
} else if ss.msg.Ask == string(types.AskTypePlanModeRespond) {
|
||||
// Plan mode respond: use ToolRenderer
|
||||
bodyContent = ss.toolRenderer.GeneratePlanModeRespondBody(currentBuffer)
|
||||
} else if ss.msg.Ask == string(types.AskTypeCommand) {
|
||||
// Command approval: no body needed - header shows command, output shown separately later
|
||||
bodyContent = ""
|
||||
} else {
|
||||
// For other ask types, render as-is
|
||||
bodyContent = currentBuffer
|
||||
}
|
||||
} else if ss.sayType == string(types.SayTypeTool) {
|
||||
// Tool execution (SAY): use ToolRenderer for body
|
||||
var tool types.ToolMessage
|
||||
if err := json.Unmarshal([]byte(currentBuffer), &tool); err == nil {
|
||||
bodyContent = ss.toolRenderer.GenerateToolContentBody(&tool)
|
||||
}
|
||||
} else if ss.sayType == string(types.SayTypeCommand) {
|
||||
// Command output
|
||||
bodyContent = "```shell\n" + currentBuffer + "\n```"
|
||||
// Render markdown only in rich mode and TTY
|
||||
if ss.shouldMarkdown && ss.outputFormat != "plain" && isTTY() {
|
||||
rendered, err := ss.mdRenderer.Render(bodyContent)
|
||||
if err == nil {
|
||||
bodyContent = rendered
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For other types (reasoning, text, etc.), render markdown as-is
|
||||
if ss.shouldMarkdown && ss.outputFormat != "plain" && isTTY() {
|
||||
rendered, err := ss.mdRenderer.Render(currentBuffer)
|
||||
if err == nil {
|
||||
bodyContent = rendered
|
||||
} else {
|
||||
bodyContent = currentBuffer
|
||||
}
|
||||
} else {
|
||||
bodyContent = currentBuffer
|
||||
}
|
||||
}
|
||||
|
||||
// Print the body content
|
||||
if bodyContent != "" {
|
||||
if !strings.HasSuffix(bodyContent, "\n") {
|
||||
output.Print(bodyContent)
|
||||
output.Println("")
|
||||
} else {
|
||||
output.Print(bodyContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// generateRichHeader generates a contextual header for the segment
|
||||
func (ss *StreamingSegment) generateRichHeader() string {
|
||||
switch ss.sayType {
|
||||
case string(types.SayTypeReasoning):
|
||||
return "### Cline is thinking\n"
|
||||
|
||||
case string(types.SayTypeText):
|
||||
return "### Cline responds\n"
|
||||
|
||||
case string(types.SayTypeCompletionResult):
|
||||
return "### Task completed\n"
|
||||
|
||||
case string(types.SayTypeTool):
|
||||
return ss.generateToolHeader()
|
||||
|
||||
case "ask":
|
||||
// Check the specific ask type
|
||||
if ss.msg.Ask == string(types.AskTypePlanModeRespond) {
|
||||
return ss.toolRenderer.GeneratePlanModeRespondHeader()
|
||||
}
|
||||
|
||||
// For tool approvals, show proper tool header
|
||||
if ss.msg.Ask == string(types.AskTypeTool) {
|
||||
var tool types.ToolMessage
|
||||
if err := json.Unmarshal([]byte(ss.msg.Text), &tool); err == nil {
|
||||
// Use ToolRenderer for approval header with "wants to" verbs
|
||||
return ss.toolRenderer.RenderToolApprovalHeader(&tool)
|
||||
}
|
||||
}
|
||||
|
||||
// For command approvals, show command header
|
||||
if ss.msg.Ask == string(types.AskTypeCommand) {
|
||||
command := strings.TrimSpace(ss.msg.Text)
|
||||
if strings.HasSuffix(command, "REQ_APP") {
|
||||
command = strings.TrimSuffix(command, "REQ_APP")
|
||||
command = strings.TrimSpace(command)
|
||||
}
|
||||
return fmt.Sprintf("### Cline wants to run `%s`\n", command)
|
||||
}
|
||||
|
||||
// For followup questions, show question header
|
||||
if ss.msg.Ask == string(types.AskTypeFollowup) {
|
||||
return ss.toolRenderer.GenerateAskFollowupHeader()
|
||||
}
|
||||
|
||||
// For other ask types, show generic message
|
||||
return fmt.Sprintf("### Cline is asking (%s)\n", ss.msg.Ask)
|
||||
|
||||
default:
|
||||
return fmt.Sprintf("### %s\n", ss.prefix)
|
||||
}
|
||||
}
|
||||
|
||||
// generateToolHeader generates a contextual header for tool operations
|
||||
func (ss *StreamingSegment) generateToolHeader() string {
|
||||
// Parse tool JSON from message text
|
||||
var tool types.ToolMessage
|
||||
if err := json.Unmarshal([]byte(ss.msg.Text), &tool); err != nil {
|
||||
return "### Tool operation\n"
|
||||
}
|
||||
|
||||
// Use unified ToolRenderer for header
|
||||
return ss.toolRenderer.RenderToolExecutionHeader(&tool)
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
)
|
||||
|
||||
// StreamingDisplay manages streaming message display with deduplication
|
||||
type StreamingDisplay struct {
|
||||
mu sync.RWMutex
|
||||
state *types.ConversationState
|
||||
renderer *Renderer
|
||||
dedupe *MessageDeduplicator
|
||||
activeSegment *StreamingSegment
|
||||
mdRenderer *MarkdownRenderer
|
||||
}
|
||||
|
||||
// NewStreamingDisplay creates a new streaming display manager
|
||||
func NewStreamingDisplay(state *types.ConversationState, renderer *Renderer) *StreamingDisplay {
|
||||
mdRenderer, err := NewMarkdownRenderer()
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Failed to initialize markdown renderer: %v", err))
|
||||
}
|
||||
|
||||
return &StreamingDisplay{
|
||||
state: state,
|
||||
renderer: renderer,
|
||||
dedupe: NewMessageDeduplicator(),
|
||||
mdRenderer: mdRenderer,
|
||||
}
|
||||
}
|
||||
|
||||
// HandlePartialMessage processes partial messages with streaming support
|
||||
func (s *StreamingDisplay) HandlePartialMessage(msg *types.ClineMessage) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Check for deduplication
|
||||
if s.dedupe.IsDuplicate(msg) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Segment-based header-only streaming
|
||||
// Partial stream only shows headers immediately, state stream will handle content bodies
|
||||
sayType := msg.Say
|
||||
if msg.Type == types.MessageTypeAsk {
|
||||
sayType = "ask"
|
||||
}
|
||||
|
||||
// Detect segment boundary
|
||||
if s.activeSegment != nil && s.activeSegment.sayType != sayType {
|
||||
// Just cleanup, don't freeze (no body to print)
|
||||
s.activeSegment = nil
|
||||
}
|
||||
|
||||
// On first partial message for a new segment type, create segment (prints header)
|
||||
if s.activeSegment == nil && msg.Partial {
|
||||
shouldMd := s.shouldRenderMarkdown(sayType)
|
||||
prefix := s.getPrefix(sayType)
|
||||
// NewStreamingSegment prints the header immediately
|
||||
s.activeSegment = NewStreamingSegment(sayType, prefix, s.mdRenderer, shouldMd, msg, s.renderer.outputFormat)
|
||||
// Header printed, done - don't append text or freeze
|
||||
return nil
|
||||
}
|
||||
|
||||
// For subsequent partial messages, do nothing (header already shown)
|
||||
if msg.Partial {
|
||||
return nil
|
||||
}
|
||||
|
||||
// When message is complete (partial=false), render the content body
|
||||
if s.activeSegment != nil {
|
||||
// Had an active segment from partial messages - freeze to render body
|
||||
s.activeSegment.AppendText(msg.Text)
|
||||
s.activeSegment.Freeze()
|
||||
s.activeSegment = nil
|
||||
} else if !msg.Partial {
|
||||
// Message arrived complete without partial phase - create segment and render immediately
|
||||
shouldMd := s.shouldRenderMarkdown(sayType)
|
||||
prefix := s.getPrefix(sayType)
|
||||
segment := NewStreamingSegment(sayType, prefix, s.mdRenderer, shouldMd, msg, s.renderer.outputFormat)
|
||||
segment.AppendText(msg.Text)
|
||||
segment.Freeze()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *StreamingDisplay) shouldRenderMarkdown(sayType string) bool {
|
||||
switch sayType {
|
||||
case string(types.SayTypeReasoning), string(types.SayTypeText), string(types.SayTypeCompletionResult), string(types.SayTypeTool), "ask":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *StreamingDisplay) getPrefix(sayType string) string {
|
||||
switch sayType {
|
||||
case string(types.SayTypeReasoning):
|
||||
return "THINKING"
|
||||
case string(types.SayTypeText):
|
||||
return "CLINE"
|
||||
case string(types.SayTypeCompletionResult):
|
||||
return "RESULT"
|
||||
case "ask":
|
||||
return "ASK"
|
||||
case string(types.SayTypeCommand):
|
||||
return "TERMINAL"
|
||||
default:
|
||||
return strings.ToUpper(sayType)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *StreamingDisplay) FreezeActiveSegment() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.activeSegment != nil {
|
||||
s.activeSegment.Freeze()
|
||||
s.activeSegment = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup cleans up streaming display resources
|
||||
func (s *StreamingDisplay) Cleanup() {
|
||||
s.FreezeActiveSegment()
|
||||
if s.dedupe != nil {
|
||||
s.dedupe.Stop()
|
||||
}
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/clerror"
|
||||
)
|
||||
|
||||
// ErrorSeverity represents the severity level of an error
|
||||
type ErrorSeverity string
|
||||
|
||||
const (
|
||||
SeverityCritical ErrorSeverity = "critical"
|
||||
SeverityWarning ErrorSeverity = "warning"
|
||||
SeverityInfo ErrorSeverity = "info"
|
||||
)
|
||||
|
||||
// SystemMessageRenderer handles rendering of system messages (errors, warnings, info)
|
||||
type SystemMessageRenderer struct {
|
||||
renderer *Renderer
|
||||
mdRenderer *MarkdownRenderer
|
||||
outputFormat string
|
||||
}
|
||||
|
||||
// NewSystemMessageRenderer creates a new system message renderer
|
||||
func NewSystemMessageRenderer(renderer *Renderer, mdRenderer *MarkdownRenderer, outputFormat string) *SystemMessageRenderer {
|
||||
return &SystemMessageRenderer{
|
||||
renderer: renderer,
|
||||
mdRenderer: mdRenderer,
|
||||
outputFormat: outputFormat,
|
||||
}
|
||||
}
|
||||
|
||||
// RenderError renders a beautiful error message with optional details
|
||||
func (sr *SystemMessageRenderer) RenderError(severity ErrorSeverity, title, body string, details map[string]string) error {
|
||||
var colorMarkdown string
|
||||
|
||||
switch severity {
|
||||
case SeverityCritical:
|
||||
colorMarkdown = "**[ERROR]**"
|
||||
case SeverityWarning:
|
||||
colorMarkdown = "**[WARNING]**"
|
||||
case SeverityInfo:
|
||||
colorMarkdown = "**[INFO]**"
|
||||
}
|
||||
|
||||
// Build the error message in markdown
|
||||
var parts []string
|
||||
|
||||
// Header
|
||||
header := fmt.Sprintf("### %s %s", colorMarkdown, title)
|
||||
parts = append(parts, header)
|
||||
|
||||
// Body
|
||||
if body != "" {
|
||||
parts = append(parts, "", body)
|
||||
}
|
||||
|
||||
// Details
|
||||
if len(details) > 0 {
|
||||
parts = append(parts, "", "**Details:**")
|
||||
for key, value := range details {
|
||||
parts = append(parts, fmt.Sprintf("- %s: `%s`", key, value))
|
||||
}
|
||||
}
|
||||
|
||||
markdown := strings.Join(parts, "\n")
|
||||
rendered := sr.renderer.RenderMarkdown(markdown)
|
||||
fmt.Printf("\n%s\n", rendered)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenderBalanceError renders a special balance/credits error with helpful info
|
||||
func (sr *SystemMessageRenderer) RenderBalanceError(err *clerror.ClineError) error {
|
||||
var parts []string
|
||||
|
||||
// Header
|
||||
parts = append(parts, "### **[ERROR]** Credit Limit Reached")
|
||||
parts = append(parts, "")
|
||||
|
||||
// Message - prefer detail message from error.details, fallback to main message
|
||||
message := err.Message
|
||||
if detailMsg := err.GetDetailMessage(); detailMsg != "" {
|
||||
message = detailMsg
|
||||
}
|
||||
parts = append(parts, message)
|
||||
parts = append(parts, "")
|
||||
|
||||
// Account Balance section
|
||||
parts = append(parts, "**Account Balance:**")
|
||||
|
||||
// Current balance
|
||||
if balance := err.GetCurrentBalance(); balance != nil {
|
||||
parts = append(parts, fmt.Sprintf("- Current Balance: **$%.2f**", *balance))
|
||||
}
|
||||
|
||||
// Total spent
|
||||
if spent := err.GetTotalSpent(); spent != nil {
|
||||
parts = append(parts, fmt.Sprintf("- Total Spent: $%.2f", *spent))
|
||||
}
|
||||
|
||||
// Promotions applied
|
||||
if promos := err.GetTotalPromotions(); promos != nil {
|
||||
parts = append(parts, fmt.Sprintf("- Promotions Applied: $%.2f", *promos))
|
||||
}
|
||||
|
||||
parts = append(parts, "")
|
||||
|
||||
// Buy credits link
|
||||
if url := err.GetBuyCreditsURL(); url != "" {
|
||||
parts = append(parts, fmt.Sprintf("**→ Buy credits:** %s", url))
|
||||
} else {
|
||||
// Fallback - show both personal and org URLs
|
||||
parts = append(parts, "**→ Buy credits:**")
|
||||
parts = append(parts, " - Personal: https://app.cline.bot/dashboard/account?tab=credits")
|
||||
parts = append(parts, " - Organization: https://app.cline.bot/dashboard/organization?tab=credits")
|
||||
}
|
||||
|
||||
// Request ID (less prominent at the end)
|
||||
if err.RequestID != "" {
|
||||
parts = append(parts, "")
|
||||
parts = append(parts, fmt.Sprintf("*Request ID: %s*", err.RequestID))
|
||||
}
|
||||
|
||||
markdown := strings.Join(parts, "\n")
|
||||
rendered := sr.renderer.RenderMarkdown(markdown)
|
||||
fmt.Printf("\n%s\n", rendered)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenderAuthError renders an authentication error with helpful guidance
|
||||
func (sr *SystemMessageRenderer) RenderAuthError(err *clerror.ClineError) error {
|
||||
var parts []string
|
||||
|
||||
// Header
|
||||
parts = append(parts, "### **[ERROR]** Authentication Failed")
|
||||
parts = append(parts, "")
|
||||
|
||||
// Message - prefer detail message if available
|
||||
message := err.Message
|
||||
if detailMsg := err.GetDetailMessage(); detailMsg != "" {
|
||||
message = detailMsg
|
||||
}
|
||||
parts = append(parts, message)
|
||||
parts = append(parts, "")
|
||||
|
||||
// Guidance
|
||||
parts = append(parts, "**Next Steps:**")
|
||||
parts = append(parts, "- Check your API key configuration")
|
||||
parts = append(parts, "- Run `cline auth` to authenticate")
|
||||
parts = append(parts, "- Verify your account status at https://app.cline.bot")
|
||||
|
||||
// Request ID
|
||||
if err.RequestID != "" {
|
||||
parts = append(parts, "")
|
||||
parts = append(parts, fmt.Sprintf("*Request ID: `%s`*", err.RequestID))
|
||||
}
|
||||
|
||||
markdown := strings.Join(parts, "\n")
|
||||
rendered := sr.renderer.RenderMarkdown(markdown)
|
||||
fmt.Printf("\n%s\n", rendered)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenderRateLimitError renders a rate limit error with request ID
|
||||
func (sr *SystemMessageRenderer) RenderRateLimitError(err *clerror.ClineError) error {
|
||||
var parts []string
|
||||
|
||||
// Header
|
||||
parts = append(parts, "### **[WARNING]** Rate Limit Reached")
|
||||
parts = append(parts, "")
|
||||
|
||||
// Message - prefer detail message if available
|
||||
message := err.Message
|
||||
if detailMsg := err.GetDetailMessage(); detailMsg != "" {
|
||||
message = detailMsg
|
||||
}
|
||||
parts = append(parts, message)
|
||||
parts = append(parts, "")
|
||||
|
||||
// Guidance
|
||||
parts = append(parts, "The API will automatically retry this request.")
|
||||
|
||||
// Request ID
|
||||
if err.RequestID != "" {
|
||||
parts = append(parts, "")
|
||||
parts = append(parts, fmt.Sprintf("*Request ID: `%s`*", err.RequestID))
|
||||
}
|
||||
|
||||
markdown := strings.Join(parts, "\n")
|
||||
rendered := sr.renderer.RenderMarkdown(markdown)
|
||||
fmt.Printf("\n%s\n", rendered)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenderAPIError renders a generic API error with all available details
|
||||
func (sr *SystemMessageRenderer) RenderAPIError(err *clerror.ClineError) error {
|
||||
var parts []string
|
||||
|
||||
// Header
|
||||
parts = append(parts, "### **[ERROR]** API Request Failed")
|
||||
parts = append(parts, "")
|
||||
|
||||
// Message - prefer detail message if available
|
||||
message := err.Message
|
||||
if detailMsg := err.GetDetailMessage(); detailMsg != "" {
|
||||
message = detailMsg
|
||||
}
|
||||
parts = append(parts, message)
|
||||
|
||||
// Details
|
||||
var details []string
|
||||
if err.RequestID != "" {
|
||||
details = append(details, fmt.Sprintf("- Request ID: `%s`", err.RequestID))
|
||||
}
|
||||
if code := err.GetCodeString(); code != "" {
|
||||
details = append(details, fmt.Sprintf("- Error Code: `%s`", code))
|
||||
}
|
||||
if err.Status > 0 {
|
||||
details = append(details, fmt.Sprintf("- HTTP Status: `%d`", err.Status))
|
||||
}
|
||||
if err.ModelID != "" {
|
||||
details = append(details, fmt.Sprintf("- Model: `%s`", err.ModelID))
|
||||
}
|
||||
if err.ProviderID != "" {
|
||||
details = append(details, fmt.Sprintf("- Provider: `%s`", err.ProviderID))
|
||||
}
|
||||
|
||||
if len(details) > 0 {
|
||||
parts = append(parts, "")
|
||||
parts = append(parts, "**Details:**")
|
||||
parts = append(parts, strings.Join(details, "\n"))
|
||||
}
|
||||
|
||||
markdown := strings.Join(parts, "\n")
|
||||
rendered := sr.renderer.RenderMarkdown(markdown)
|
||||
fmt.Printf("\n%s\n", rendered)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenderWarning renders a warning message
|
||||
func (sr *SystemMessageRenderer) RenderWarning(title, message string) error {
|
||||
markdown := fmt.Sprintf("### **[WARNING]** %s\n\n%s", title, message)
|
||||
rendered := sr.renderer.RenderMarkdown(markdown)
|
||||
fmt.Printf("\n%s\n", rendered)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenderInfo renders an info message
|
||||
func (sr *SystemMessageRenderer) RenderInfo(title, message string) error {
|
||||
markdown := fmt.Sprintf("### **[INFO]** %s\n\n%s", title, message)
|
||||
rendered := sr.renderer.RenderMarkdown(markdown)
|
||||
fmt.Printf("\n%s\n", rendered)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenderCheckpoint renders a checkpoint creation message
|
||||
func (sr *SystemMessageRenderer) RenderCheckpoint(timestamp string, id int64) error {
|
||||
markdown := fmt.Sprintf("## [%s] Checkpoint created `%d`", timestamp, id)
|
||||
rendered := sr.renderer.RenderMarkdown(markdown)
|
||||
fmt.Printf(rendered)
|
||||
return nil
|
||||
}
|
||||
@@ -1,455 +0,0 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
)
|
||||
|
||||
// ToolRenderer provides unified rendering for tool and command messages
|
||||
type ToolRenderer struct {
|
||||
mdRenderer *MarkdownRenderer
|
||||
outputFormat string
|
||||
}
|
||||
|
||||
// NewToolRenderer creates a new tool renderer
|
||||
func NewToolRenderer(mdRenderer *MarkdownRenderer, outputFormat string) *ToolRenderer {
|
||||
return &ToolRenderer{
|
||||
mdRenderer: mdRenderer,
|
||||
outputFormat: outputFormat,
|
||||
}
|
||||
}
|
||||
|
||||
// RenderToolApprovalRequest renders a tool approval request ("Cline wants to...")
|
||||
func (tr *ToolRenderer) RenderToolApprovalRequest(tool *types.ToolMessage) string {
|
||||
var output strings.Builder
|
||||
|
||||
// Generate header
|
||||
header := tr.generateToolHeader(tool, "wants to")
|
||||
rendered := tr.renderMarkdown(header)
|
||||
output.WriteString(rendered)
|
||||
output.WriteString("\n")
|
||||
|
||||
// Add content preview for relevant tools
|
||||
contentPreview := tr.GenerateToolContentPreview(tool)
|
||||
if contentPreview != "" {
|
||||
output.WriteString("\n")
|
||||
output.WriteString(contentPreview)
|
||||
}
|
||||
|
||||
return output.String()
|
||||
}
|
||||
|
||||
// RenderToolExecution renders a completed tool execution ("Cline is ...ing")
|
||||
func (tr *ToolRenderer) RenderToolExecution(tool *types.ToolMessage) string {
|
||||
var output strings.Builder
|
||||
|
||||
// Generate header
|
||||
header := tr.generateToolHeader(tool, "is")
|
||||
rendered := tr.renderMarkdown(header)
|
||||
output.WriteString("\n")
|
||||
output.WriteString(rendered)
|
||||
output.WriteString("\n")
|
||||
|
||||
// Add content body for relevant tools
|
||||
contentBody := tr.GenerateToolContentBody(tool)
|
||||
if contentBody != "" {
|
||||
output.WriteString("\n")
|
||||
output.WriteString(contentBody)
|
||||
output.WriteString("\n")
|
||||
}
|
||||
|
||||
return output.String()
|
||||
}
|
||||
|
||||
// RenderToolExecutionHeader renders just the header for streaming (no body)
|
||||
func (tr *ToolRenderer) RenderToolExecutionHeader(tool *types.ToolMessage) string {
|
||||
header := tr.generateToolHeader(tool, "is")
|
||||
return header
|
||||
}
|
||||
|
||||
// RenderToolApprovalHeader renders just the header for approval requests (no body)
|
||||
func (tr *ToolRenderer) RenderToolApprovalHeader(tool *types.ToolMessage) string {
|
||||
header := tr.generateToolHeader(tool, "wants to")
|
||||
return header
|
||||
}
|
||||
|
||||
// generateToolHeader generates the markdown header for a tool message
|
||||
func (tr *ToolRenderer) generateToolHeader(tool *types.ToolMessage, verbTense string) string {
|
||||
var verb string
|
||||
var action string
|
||||
|
||||
switch tool.Tool {
|
||||
case string(types.ToolTypeEditedExistingFile):
|
||||
if verbTense == "wants to" {
|
||||
action = "wants to edit"
|
||||
} else {
|
||||
action = "is editing"
|
||||
}
|
||||
return fmt.Sprintf("### Cline %s `%s`", action, tool.Path)
|
||||
|
||||
case string(types.ToolTypeNewFileCreated):
|
||||
if verbTense == "wants to" {
|
||||
action = "wants to write"
|
||||
} else {
|
||||
action = "is writing"
|
||||
}
|
||||
return fmt.Sprintf("### Cline %s `%s`", action, tool.Path)
|
||||
|
||||
case string(types.ToolTypeReadFile):
|
||||
if verbTense == "wants to" {
|
||||
action = "wants to read"
|
||||
} else {
|
||||
action = "is reading"
|
||||
}
|
||||
return fmt.Sprintf("### Cline %s `%s`", action, tool.Path)
|
||||
|
||||
case string(types.ToolTypeFileDeleted):
|
||||
if verbTense == "wants to" {
|
||||
action = "wants to delete"
|
||||
} else {
|
||||
action = "is deleting"
|
||||
}
|
||||
return fmt.Sprintf("### Cline %s `%s`", action, tool.Path)
|
||||
|
||||
case string(types.ToolTypeListFilesTopLevel):
|
||||
if verbTense == "wants to" {
|
||||
action = "wants to list files in"
|
||||
} else {
|
||||
action = "is listing files in"
|
||||
}
|
||||
return fmt.Sprintf("### Cline %s `%s`", action, tool.Path)
|
||||
|
||||
case string(types.ToolTypeListFilesRecursive):
|
||||
if verbTense == "wants to" {
|
||||
action = "wants to recursively list files in"
|
||||
} else {
|
||||
action = "is recursively listing files in"
|
||||
}
|
||||
return fmt.Sprintf("### Cline %s `%s`", action, tool.Path)
|
||||
|
||||
case string(types.ToolTypeSearchFiles):
|
||||
if tool.Regex != "" && tool.Path != "" {
|
||||
if verbTense == "wants to" {
|
||||
action = "wants to search for"
|
||||
} else {
|
||||
action = "is searching for"
|
||||
}
|
||||
return fmt.Sprintf("### Cline %s `%s` in `%s`", action, tool.Regex, tool.Path)
|
||||
} else if tool.Regex != "" {
|
||||
if verbTense == "wants to" {
|
||||
action = "wants to search for"
|
||||
} else {
|
||||
action = "is searching for"
|
||||
}
|
||||
return fmt.Sprintf("### Cline %s `%s`", action, tool.Regex)
|
||||
} else {
|
||||
if verbTense == "wants to" {
|
||||
return "### Cline wants to search files"
|
||||
} else {
|
||||
return "### Cline is searching files"
|
||||
}
|
||||
}
|
||||
|
||||
case string(types.ToolTypeWebFetch):
|
||||
if verbTense == "wants to" {
|
||||
action = "wants to fetch"
|
||||
} else {
|
||||
action = "is fetching"
|
||||
}
|
||||
return fmt.Sprintf("### Cline %s `%s`", action, tool.Path)
|
||||
|
||||
case string(types.ToolTypeListCodeDefinitionNames):
|
||||
if verbTense == "wants to" {
|
||||
action = "wants to list code definitions in"
|
||||
} else {
|
||||
action = "is listing code definitions in"
|
||||
}
|
||||
return fmt.Sprintf("### Cline %s `%s`", action, tool.Path)
|
||||
|
||||
case string(types.ToolTypeSummarizeTask):
|
||||
if verbTense == "wants to" {
|
||||
return "### Cline wants to condense the conversation"
|
||||
} else {
|
||||
return "### Cline condensed the conversation"
|
||||
}
|
||||
|
||||
default:
|
||||
if verbTense == "wants to" {
|
||||
verb = "wants to use"
|
||||
} else {
|
||||
verb = "is using"
|
||||
}
|
||||
return fmt.Sprintf("### Cline %s tool: %s", verb, tool.Tool)
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateToolContentPreview generates content preview for approval requests
|
||||
func (tr *ToolRenderer) GenerateToolContentPreview(tool *types.ToolMessage) string {
|
||||
if tool.Content == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch tool.Tool {
|
||||
case string(types.ToolTypeEditedExistingFile):
|
||||
// Show diff for edits
|
||||
diffMarkdown := fmt.Sprintf("```diff\n%s\n```", tool.Content)
|
||||
return tr.renderMarkdown(diffMarkdown)
|
||||
|
||||
case string(types.ToolTypeNewFileCreated):
|
||||
// Show content preview for new files (truncated)
|
||||
preview := strings.TrimSpace(tool.Content)
|
||||
if len(preview) > 500 {
|
||||
preview = preview[:500] + "..."
|
||||
}
|
||||
previewMd := fmt.Sprintf("```\n%s\n```", preview)
|
||||
return tr.renderMarkdown(previewMd)
|
||||
|
||||
case string(types.ToolTypeReadFile), string(types.ToolTypeWebFetch), string(types.ToolTypeFileDeleted):
|
||||
// No preview for read/fetch operations
|
||||
return ""
|
||||
|
||||
default:
|
||||
// For other tools, show truncated content if available
|
||||
preview := strings.TrimSpace(tool.Content)
|
||||
if len(preview) > 200 {
|
||||
preview = preview[:200] + "..."
|
||||
}
|
||||
if preview != "" {
|
||||
return fmt.Sprintf("Preview: %s", preview)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateToolContentBody generates full content for completed executions
|
||||
func (tr *ToolRenderer) GenerateToolContentBody(tool *types.ToolMessage) string {
|
||||
if tool.Content == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Use enhanced tool result parser for supported tools
|
||||
toolParser := NewToolResultParser(tr.mdRenderer)
|
||||
|
||||
switch tool.Tool {
|
||||
case string(types.ToolTypeReadFile),
|
||||
string(types.ToolTypeFileDeleted):
|
||||
// readFile: show header only, no body
|
||||
return ""
|
||||
|
||||
case string(types.ToolTypeListFilesTopLevel),
|
||||
string(types.ToolTypeListFilesRecursive),
|
||||
string(types.ToolTypeListCodeDefinitionNames),
|
||||
string(types.ToolTypeSearchFiles),
|
||||
string(types.ToolTypeWebFetch):
|
||||
// Use parser for structured output
|
||||
preview := toolParser.ParseToolResult(tool)
|
||||
return tr.renderMarkdown(preview)
|
||||
|
||||
case string(types.ToolTypeEditedExistingFile):
|
||||
// Show the diff
|
||||
diffMarkdown := fmt.Sprintf("```diff\n%s\n```", tool.Content)
|
||||
return tr.renderMarkdown(diffMarkdown)
|
||||
|
||||
case string(types.ToolTypeNewFileCreated):
|
||||
// Show file content preview
|
||||
preview := strings.TrimSpace(tool.Content)
|
||||
if len(preview) > 1000 {
|
||||
preview = preview[:1000] + "..."
|
||||
}
|
||||
contentMd := fmt.Sprintf("```\n%s\n```", preview)
|
||||
return tr.renderMarkdown(contentMd)
|
||||
|
||||
default:
|
||||
// For unknown tools, show content as-is
|
||||
if len(tool.Content) > 500 {
|
||||
return tool.Content[:500] + "..."
|
||||
}
|
||||
return tool.Content
|
||||
}
|
||||
}
|
||||
|
||||
// RenderCommandApprovalRequest renders a command approval request
|
||||
func (tr *ToolRenderer) RenderCommandApprovalRequest(command string, autoApprovalConflict bool) string {
|
||||
var output strings.Builder
|
||||
|
||||
// Clean command
|
||||
command = strings.TrimSpace(command)
|
||||
if strings.HasSuffix(command, "REQ_APP") {
|
||||
command = strings.TrimSuffix(command, "REQ_APP")
|
||||
command = strings.TrimSpace(command)
|
||||
autoApprovalConflict = true
|
||||
}
|
||||
|
||||
// Generate header
|
||||
header := fmt.Sprintf("### Cline wants to run `%s`", command)
|
||||
rendered := tr.renderMarkdown(header)
|
||||
output.WriteString(rendered)
|
||||
output.WriteString("\n")
|
||||
|
||||
// Show command in code block
|
||||
cmdBlock := fmt.Sprintf("```shell\n%s\n```", command)
|
||||
cmdRendered := tr.renderMarkdown(cmdBlock)
|
||||
output.WriteString("\n")
|
||||
output.WriteString(cmdRendered)
|
||||
|
||||
// Add warning if needed
|
||||
if autoApprovalConflict {
|
||||
output.WriteString("\nWARNING: The model has determined this command requires explicit approval.\n")
|
||||
}
|
||||
|
||||
return output.String()
|
||||
}
|
||||
|
||||
// RenderCommandExecution renders a command execution announcement
|
||||
func (tr *ToolRenderer) RenderCommandExecution(command string) string {
|
||||
command = strings.TrimSpace(command)
|
||||
header := fmt.Sprintf("### Cline is running `%s`", command)
|
||||
rendered := tr.renderMarkdown(header)
|
||||
return "\n" + rendered + "\n"
|
||||
}
|
||||
|
||||
// RenderCommandOutput renders command output
|
||||
func (tr *ToolRenderer) RenderCommandOutput(output string) string {
|
||||
var result strings.Builder
|
||||
|
||||
header := "### Terminal output"
|
||||
rendered := tr.renderMarkdown(header)
|
||||
result.WriteString("\n")
|
||||
result.WriteString(rendered)
|
||||
result.WriteString("\n\n")
|
||||
|
||||
// Show output in code block
|
||||
outputBlock := fmt.Sprintf("```\n%s\n```", strings.TrimSpace(output))
|
||||
outputRendered := tr.renderMarkdown(outputBlock)
|
||||
result.WriteString(outputRendered)
|
||||
result.WriteString("\n")
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// RenderUserResponse renders user approval/rejection feedback
|
||||
func (tr *ToolRenderer) RenderUserResponse(approved bool, feedback string) string {
|
||||
var symbol, status string
|
||||
|
||||
if approved {
|
||||
symbol = "✓"
|
||||
status = "Approved"
|
||||
} else {
|
||||
symbol = "✗"
|
||||
status = "Rejected"
|
||||
}
|
||||
|
||||
if feedback != "" {
|
||||
return fmt.Sprintf("%s %s with feedback: %s\n", symbol, status, feedback)
|
||||
}
|
||||
return fmt.Sprintf("%s %s\n", symbol, status)
|
||||
}
|
||||
|
||||
// renderMarkdown renders markdown if not in plain mode and in a TTY
|
||||
func (tr *ToolRenderer) renderMarkdown(markdown string) string {
|
||||
// Skip markdown rendering if plain mode or not in TTY
|
||||
if tr.outputFormat == "plain" || !isTTY() {
|
||||
return markdown
|
||||
}
|
||||
|
||||
if tr.mdRenderer == nil {
|
||||
return markdown
|
||||
}
|
||||
|
||||
rendered, err := tr.mdRenderer.Render(markdown)
|
||||
if err != nil {
|
||||
return markdown
|
||||
}
|
||||
|
||||
return rendered
|
||||
}
|
||||
|
||||
// GenerateAskFollowupHeader generates the header for followup questions
|
||||
func (tr *ToolRenderer) GenerateAskFollowupHeader() string {
|
||||
return "### Cline has a question\n"
|
||||
}
|
||||
|
||||
// GenerateAskFollowupBody generates the body content for followup questions
|
||||
func (tr *ToolRenderer) GenerateAskFollowupBody(messageText string) string {
|
||||
var question string
|
||||
var options []string
|
||||
|
||||
// Try to parse as JSON
|
||||
var askData types.AskData
|
||||
if err := json.Unmarshal([]byte(messageText), &askData); err == nil {
|
||||
question = askData.Question
|
||||
options = askData.Options
|
||||
} else {
|
||||
question = messageText
|
||||
}
|
||||
|
||||
if question == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Build the body
|
||||
var body strings.Builder
|
||||
|
||||
// Render the question
|
||||
rendered := tr.renderMarkdown(question)
|
||||
body.WriteString(rendered)
|
||||
|
||||
// Add options if available
|
||||
if len(options) > 0 {
|
||||
body.WriteString("\n\nOptions:\n")
|
||||
for i, option := range options {
|
||||
body.WriteString(fmt.Sprintf("%d. %s\n", i+1, option))
|
||||
}
|
||||
}
|
||||
|
||||
return body.String()
|
||||
}
|
||||
|
||||
// GeneratePlanModeRespondHeader generates the header for plan mode responses
|
||||
func (tr *ToolRenderer) GeneratePlanModeRespondHeader() string {
|
||||
return "### Cline has a plan\n"
|
||||
}
|
||||
|
||||
// GeneratePlanModeRespondBody generates the body content for plan mode responses
|
||||
func (tr *ToolRenderer) GeneratePlanModeRespondBody(messageText string) string {
|
||||
var response string
|
||||
var options []string
|
||||
|
||||
// Try to parse as JSON
|
||||
type PlanModeResponse struct {
|
||||
Response string `json:"response"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
var planData PlanModeResponse
|
||||
if err := json.Unmarshal([]byte(messageText), &planData); err == nil {
|
||||
response = planData.Response
|
||||
options = planData.Options
|
||||
} else {
|
||||
response = messageText
|
||||
}
|
||||
|
||||
if response == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Build the body
|
||||
var body strings.Builder
|
||||
|
||||
// Render the response
|
||||
rendered := tr.renderMarkdown(response)
|
||||
body.WriteString(rendered)
|
||||
|
||||
// Add options if available
|
||||
if len(options) > 0 {
|
||||
body.WriteString("\n\nOptions:\n")
|
||||
for i, option := range options {
|
||||
body.WriteString(fmt.Sprintf("%d. %s\n", i+1, option))
|
||||
}
|
||||
}
|
||||
|
||||
return body.String()
|
||||
}
|
||||
@@ -1,295 +0,0 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
)
|
||||
|
||||
// ToolResultParser handles parsing and formatting tool results for display
|
||||
type ToolResultParser struct {
|
||||
maxPreviewLines int
|
||||
maxPreviewChars int
|
||||
mdRenderer *MarkdownRenderer
|
||||
}
|
||||
|
||||
// NewToolResultParser creates a new tool result parser
|
||||
func NewToolResultParser(mdRenderer *MarkdownRenderer) *ToolResultParser {
|
||||
return &ToolResultParser{
|
||||
maxPreviewLines: 15,
|
||||
maxPreviewChars: 500,
|
||||
mdRenderer: mdRenderer,
|
||||
}
|
||||
}
|
||||
|
||||
// ParseReadFile formats readFile tool results with smart preview
|
||||
func (p *ToolResultParser) ParseReadFile(content, path string) string {
|
||||
lines := strings.Split(content, "\n")
|
||||
totalLines := len(lines)
|
||||
|
||||
// Get file extension for syntax highlighting
|
||||
ext := filepath.Ext(path)
|
||||
lang := p.detectLanguage(ext)
|
||||
|
||||
var preview strings.Builder
|
||||
|
||||
// Show header with line count
|
||||
preview.WriteString(fmt.Sprintf("*%d lines*\n\n", totalLines))
|
||||
|
||||
// Show preview of content
|
||||
previewLines := p.maxPreviewLines
|
||||
if totalLines < previewLines {
|
||||
previewLines = totalLines
|
||||
}
|
||||
|
||||
preview.WriteString(fmt.Sprintf("```%s\n", lang))
|
||||
for i := 0; i < previewLines; i++ {
|
||||
preview.WriteString(lines[i])
|
||||
preview.WriteString("\n")
|
||||
}
|
||||
|
||||
if totalLines > previewLines {
|
||||
preview.WriteString("...\n")
|
||||
}
|
||||
preview.WriteString("```\n")
|
||||
|
||||
if totalLines > previewLines {
|
||||
preview.WriteString(fmt.Sprintf("\n*[Content truncated - showing %d of %d lines]*", previewLines, totalLines))
|
||||
}
|
||||
|
||||
return preview.String()
|
||||
}
|
||||
|
||||
// ParseListFiles formats listFiles tool results with directory tree
|
||||
func (p *ToolResultParser) ParseListFiles(content, path string) string {
|
||||
if content == "" || content == "No files found." {
|
||||
return "*No files found*"
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(content), "\n")
|
||||
|
||||
// Check for truncation message
|
||||
var truncationMsg string
|
||||
lastLine := lines[len(lines)-1]
|
||||
if strings.Contains(lastLine, "File list truncated") {
|
||||
truncationMsg = lastLine
|
||||
lines = lines[:len(lines)-1]
|
||||
}
|
||||
|
||||
totalFiles := len(lines)
|
||||
|
||||
var result strings.Builder
|
||||
result.WriteString(fmt.Sprintf("*%d %s*\n\n", totalFiles, p.pluralize(totalFiles, "file", "files")))
|
||||
|
||||
// Show up to 20 files in tree format
|
||||
maxShow := 20
|
||||
if totalFiles < maxShow {
|
||||
maxShow = totalFiles
|
||||
}
|
||||
|
||||
result.WriteString("```\n")
|
||||
for i := 0; i < maxShow; i++ {
|
||||
line := lines[i]
|
||||
// Add tree characters for better visualization
|
||||
if strings.HasPrefix(line, "🔒 ") {
|
||||
result.WriteString("├── 🔒 ")
|
||||
result.WriteString(strings.TrimPrefix(line, "🔒 "))
|
||||
} else {
|
||||
result.WriteString("├── ")
|
||||
result.WriteString(line)
|
||||
}
|
||||
result.WriteString("\n")
|
||||
}
|
||||
|
||||
if totalFiles > maxShow {
|
||||
result.WriteString("└── ...\n")
|
||||
}
|
||||
result.WriteString("```\n")
|
||||
|
||||
if totalFiles > maxShow {
|
||||
result.WriteString(fmt.Sprintf("\n*[Showing %d of %d files]*", maxShow, totalFiles))
|
||||
}
|
||||
|
||||
if truncationMsg != "" {
|
||||
result.WriteString(fmt.Sprintf("\n\n*%s*", truncationMsg))
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// ParseSearchFiles formats searchFiles tool results with context
|
||||
func (p *ToolResultParser) ParseSearchFiles(content string) string {
|
||||
if content == "" || content == "Found 0 results." {
|
||||
return "*No results found*"
|
||||
}
|
||||
|
||||
lines := strings.Split(content, "\n")
|
||||
if len(lines) == 0 {
|
||||
return "*No results found*"
|
||||
}
|
||||
|
||||
// Extract result count from first line
|
||||
firstLine := lines[0]
|
||||
|
||||
var result strings.Builder
|
||||
result.WriteString(fmt.Sprintf("*%s*\n\n", firstLine))
|
||||
|
||||
// Parse and group results by file
|
||||
var currentFile string
|
||||
var fileResults []string
|
||||
filesShown := 0
|
||||
maxFiles := 5
|
||||
matchesShown := 0
|
||||
maxMatches := 15
|
||||
|
||||
for i := 1; i < len(lines) && filesShown < maxFiles && matchesShown < maxMatches; i++ {
|
||||
line := lines[i]
|
||||
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this is a file path (doesn't start with whitespace or line number)
|
||||
if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") && strings.Contains(line, ":") {
|
||||
// Save previous file results
|
||||
if currentFile != "" && len(fileResults) > 0 {
|
||||
result.WriteString(p.formatFileMatches(currentFile, fileResults))
|
||||
filesShown++
|
||||
}
|
||||
|
||||
currentFile = line
|
||||
fileResults = []string{}
|
||||
} else if currentFile != "" {
|
||||
// This is a match line
|
||||
fileResults = append(fileResults, strings.TrimSpace(line))
|
||||
matchesShown++
|
||||
}
|
||||
}
|
||||
|
||||
// Add last file's results
|
||||
if currentFile != "" && len(fileResults) > 0 && filesShown < maxFiles {
|
||||
result.WriteString(p.formatFileMatches(currentFile, fileResults))
|
||||
filesShown++
|
||||
}
|
||||
|
||||
// Add truncation notice
|
||||
totalMatches := strings.Count(content, "\n") - 1 // Rough estimate
|
||||
if matchesShown < totalMatches {
|
||||
result.WriteString(fmt.Sprintf("\n*[Showing %d results - see full output for all matches]*", matchesShown))
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// formatFileMatches formats matches for a single file
|
||||
func (p *ToolResultParser) formatFileMatches(file string, matches []string) string {
|
||||
var result strings.Builder
|
||||
|
||||
// Parse file path and extension for syntax highlighting
|
||||
ext := filepath.Ext(file)
|
||||
lang := p.detectLanguage(ext)
|
||||
|
||||
result.WriteString(fmt.Sprintf("**%s** (%d %s)\n", file, len(matches), p.pluralize(len(matches), "match", "matches")))
|
||||
result.WriteString(fmt.Sprintf("```%s\n", lang))
|
||||
|
||||
maxMatches := 5
|
||||
for i, match := range matches {
|
||||
if i >= maxMatches {
|
||||
result.WriteString("...\n")
|
||||
break
|
||||
}
|
||||
result.WriteString(match)
|
||||
result.WriteString("\n")
|
||||
}
|
||||
|
||||
result.WriteString("```\n\n")
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// ParseCodeDefinitions formats listCodeDefinitionNames tool results
|
||||
func (p *ToolResultParser) ParseCodeDefinitions(content string) string {
|
||||
if content == "" || content == "No source code definitions found." {
|
||||
return "*No code definitions found*"
|
||||
}
|
||||
|
||||
// Return the full content as-is
|
||||
return content
|
||||
}
|
||||
|
||||
// ParseWebFetch formats webFetch tool results with content preview
|
||||
func (p *ToolResultParser) ParseWebFetch(content, url string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// detectLanguage returns syntax highlighting language based on file extension
|
||||
func (p *ToolResultParser) detectLanguage(ext string) string {
|
||||
langMap := map[string]string{
|
||||
".ts": "typescript",
|
||||
".tsx": "tsx",
|
||||
".js": "javascript",
|
||||
".jsx": "jsx",
|
||||
".go": "go",
|
||||
".py": "python",
|
||||
".rb": "ruby",
|
||||
".java": "java",
|
||||
".c": "c",
|
||||
".cpp": "cpp",
|
||||
".cs": "csharp",
|
||||
".php": "php",
|
||||
".sh": "bash",
|
||||
".bash": "bash",
|
||||
".zsh": "bash",
|
||||
".json": "json",
|
||||
".yaml": "yaml",
|
||||
".yml": "yaml",
|
||||
".xml": "xml",
|
||||
".html": "html",
|
||||
".css": "css",
|
||||
".scss": "scss",
|
||||
".md": "markdown",
|
||||
".sql": "sql",
|
||||
".rs": "rust",
|
||||
}
|
||||
|
||||
if lang, ok := langMap[ext]; ok {
|
||||
return lang
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// pluralize returns the correct plural form
|
||||
func (p *ToolResultParser) pluralize(count int, singular, plural string) string {
|
||||
if count == 1 {
|
||||
return singular
|
||||
}
|
||||
return plural
|
||||
}
|
||||
|
||||
// formatWordCount formats word count with appropriate unit
|
||||
func (p *ToolResultParser) formatWordCount(count int) string {
|
||||
if count < 1000 {
|
||||
return fmt.Sprintf("%d words", count)
|
||||
}
|
||||
return fmt.Sprintf("%.1fk words", float64(count)/1000.0)
|
||||
}
|
||||
|
||||
// ParseToolResult is the main entry point for parsing tool results
|
||||
func (p *ToolResultParser) ParseToolResult(tool *types.ToolMessage) string {
|
||||
switch tool.Tool {
|
||||
case "readFile":
|
||||
return p.ParseReadFile(tool.Content, tool.Path)
|
||||
case "listFilesTopLevel", "listFilesRecursive":
|
||||
return p.ParseListFiles(tool.Content, tool.Path)
|
||||
case "searchFiles":
|
||||
return p.ParseSearchFiles(tool.Content)
|
||||
case "listCodeDefinitionNames":
|
||||
return p.ParseCodeDefinitions(tool.Content)
|
||||
case "webFetch":
|
||||
return p.ParseWebFetch(tool.Content, tool.Path)
|
||||
default:
|
||||
return tool.Content
|
||||
}
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TypewriterConfig holds configuration for the typewriter effect
|
||||
type TypewriterConfig struct {
|
||||
BaseDelay time.Duration // Base delay between characters
|
||||
FastDelay time.Duration // Faster delay for common characters
|
||||
SlowDelay time.Duration // Slower delay for punctuation
|
||||
PauseDelay time.Duration // Pause after sentences
|
||||
Enabled bool // Whether typewriter effect is enabled
|
||||
RandomFactor float64 // Randomness factor (0.0 to 1.0)
|
||||
}
|
||||
|
||||
// DefaultTypewriterConfig returns the default typewriter configuration
|
||||
func DefaultTypewriterConfig() *TypewriterConfig {
|
||||
return &TypewriterConfig{
|
||||
BaseDelay: 15 * time.Millisecond,
|
||||
FastDelay: 8 * time.Millisecond,
|
||||
SlowDelay: 25 * time.Millisecond,
|
||||
PauseDelay: 150 * time.Millisecond,
|
||||
Enabled: false,
|
||||
RandomFactor: 0.3,
|
||||
}
|
||||
}
|
||||
|
||||
// TypewriterPrinter handles typewriter-style output
|
||||
type TypewriterPrinter struct {
|
||||
config *TypewriterConfig
|
||||
}
|
||||
|
||||
// NewTypewriterPrinter creates a new typewriter printer
|
||||
func NewTypewriterPrinter(config *TypewriterConfig) *TypewriterPrinter {
|
||||
if config == nil {
|
||||
config = DefaultTypewriterConfig()
|
||||
}
|
||||
return &TypewriterPrinter{
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
// Print prints text with typewriter effect
|
||||
func (tp *TypewriterPrinter) Print(text string) {
|
||||
if !tp.config.Enabled {
|
||||
fmt.Print(text)
|
||||
return
|
||||
}
|
||||
|
||||
tp.typewriterPrint(text)
|
||||
}
|
||||
|
||||
// Printf prints formatted text with typewriter effect
|
||||
func (tp *TypewriterPrinter) Printf(format string, args ...interface{}) {
|
||||
text := fmt.Sprintf(format, args...)
|
||||
tp.Print(text)
|
||||
}
|
||||
|
||||
// Println prints text with typewriter effect and adds a newline
|
||||
func (tp *TypewriterPrinter) Println(text string) {
|
||||
tp.Print(text + "\n")
|
||||
}
|
||||
|
||||
// PrintfLn prints formatted text with typewriter effect and adds a newline
|
||||
func (tp *TypewriterPrinter) PrintfLn(format string, args ...interface{}) {
|
||||
text := fmt.Sprintf(format, args...)
|
||||
tp.Println(text)
|
||||
}
|
||||
|
||||
// PrintInstant prints text immediately without typewriter effect
|
||||
func (tp *TypewriterPrinter) PrintInstant(text string) {
|
||||
fmt.Print(text)
|
||||
}
|
||||
|
||||
// PrintfInstant prints formatted text immediately without typewriter effect
|
||||
func (tp *TypewriterPrinter) PrintfInstant(format string, args ...interface{}) {
|
||||
fmt.Printf(format, args...)
|
||||
}
|
||||
|
||||
// typewriterPrint displays text with a typewriter animation effect
|
||||
func (tp *TypewriterPrinter) typewriterPrint(text string) {
|
||||
// Convert string to runes to handle Unicode properly
|
||||
runes := []rune(text)
|
||||
|
||||
for i, r := range runes {
|
||||
// Print the character
|
||||
fmt.Print(string(r))
|
||||
os.Stdout.Sync() // Force immediate output
|
||||
|
||||
// Don't add delay after the last character
|
||||
if i == len(runes)-1 {
|
||||
break
|
||||
}
|
||||
|
||||
// Determine delay based on character type
|
||||
delay := tp.getDelayForCharacter(r, i)
|
||||
|
||||
// Sleep for the calculated delay
|
||||
time.Sleep(delay)
|
||||
}
|
||||
}
|
||||
|
||||
// getDelayForCharacter returns the appropriate delay for a character
|
||||
func (tp *TypewriterPrinter) getDelayForCharacter(r rune, position int) time.Duration {
|
||||
var baseDelay time.Duration
|
||||
|
||||
switch {
|
||||
case r == '.' || r == '!' || r == '?':
|
||||
// Longer pause after sentence endings
|
||||
baseDelay = tp.config.PauseDelay
|
||||
case r == ',' || r == ';' || r == ':':
|
||||
// Medium pause after punctuation
|
||||
baseDelay = tp.config.SlowDelay
|
||||
case r == ' ':
|
||||
// Slightly faster for spaces
|
||||
baseDelay = tp.config.FastDelay
|
||||
case r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z':
|
||||
// Fast for common letters
|
||||
baseDelay = tp.config.FastDelay
|
||||
case r == '\n':
|
||||
// No delay for newlines
|
||||
return 0
|
||||
default:
|
||||
// Base delay for other characters
|
||||
baseDelay = tp.config.BaseDelay
|
||||
}
|
||||
|
||||
// Add randomness to make it feel more natural
|
||||
if tp.config.RandomFactor > 0 {
|
||||
// Simple pseudo-random based on position to ensure consistency
|
||||
randomFactor := 0.7 + (tp.config.RandomFactor * float64(position%7) / 6.0)
|
||||
baseDelay = time.Duration(float64(baseDelay) * randomFactor)
|
||||
}
|
||||
|
||||
return baseDelay
|
||||
}
|
||||
|
||||
// SetEnabled enables or disables the typewriter effect
|
||||
func (tp *TypewriterPrinter) SetEnabled(enabled bool) {
|
||||
tp.config.Enabled = enabled
|
||||
}
|
||||
|
||||
// IsEnabled returns whether the typewriter effect is enabled
|
||||
func (tp *TypewriterPrinter) IsEnabled() bool {
|
||||
return tp.config.Enabled
|
||||
}
|
||||
|
||||
// SetSpeed adjusts the typewriter speed (multiplier: 0.1 = very slow, 1.0 = normal, 2.0 = fast)
|
||||
func (tp *TypewriterPrinter) SetSpeed(multiplier float64) {
|
||||
if multiplier <= 0 {
|
||||
multiplier = 1.0
|
||||
}
|
||||
|
||||
tp.config.BaseDelay = time.Duration(float64(15*time.Millisecond) / multiplier)
|
||||
tp.config.FastDelay = time.Duration(float64(8*time.Millisecond) / multiplier)
|
||||
tp.config.SlowDelay = time.Duration(float64(25*time.Millisecond) / multiplier)
|
||||
tp.config.PauseDelay = time.Duration(float64(150*time.Millisecond) / multiplier)
|
||||
}
|
||||
|
||||
func (tp *TypewriterPrinter) PrintMessageLine(prefix, text string) {
|
||||
tp.PrintfInstant("%s: ", prefix)
|
||||
tp.Println(text)
|
||||
}
|
||||
|
||||
// Global typewriter printer instance
|
||||
var globalTypewriter = NewTypewriterPrinter(DefaultTypewriterConfig())
|
||||
|
||||
// Global convenience functions that use the global typewriter instance
|
||||
|
||||
// TypewriterPrint prints text with typewriter effect using the global instance
|
||||
func TypewriterPrint(text string) {
|
||||
globalTypewriter.Print(text)
|
||||
}
|
||||
|
||||
// TypewriterPrintf prints formatted text with typewriter effect using the global instance
|
||||
func TypewriterPrintf(format string, args ...interface{}) {
|
||||
globalTypewriter.Printf(format, args...)
|
||||
}
|
||||
|
||||
// TypewriterPrintln prints text with typewriter effect and newline using the global instance
|
||||
func TypewriterPrintln(text string) {
|
||||
globalTypewriter.Println(text)
|
||||
}
|
||||
|
||||
// TypewriterPrintfLn prints formatted text with typewriter effect and newline using the global instance
|
||||
func TypewriterPrintfLn(format string, args ...interface{}) {
|
||||
globalTypewriter.PrintfLn(format, args...)
|
||||
}
|
||||
|
||||
func TypewriterPrintMessageLine(prefix, text string) {
|
||||
globalTypewriter.PrintMessageLine(prefix, text)
|
||||
}
|
||||
|
||||
// SetGlobalTypewriterEnabled enables or disables the global typewriter effect
|
||||
func SetGlobalTypewriterEnabled(enabled bool) {
|
||||
globalTypewriter.SetEnabled(enabled)
|
||||
}
|
||||
|
||||
// SetGlobalTypewriterSpeed sets the speed of the global typewriter effect
|
||||
func SetGlobalTypewriterSpeed(multiplier float64) {
|
||||
globalTypewriter.SetSpeed(multiplier)
|
||||
}
|
||||
|
||||
// GetGlobalTypewriter returns the global typewriter instance
|
||||
func GetGlobalTypewriter() *TypewriterPrinter {
|
||||
return globalTypewriter
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user